diff --git a/API.md b/API.md
new file mode 100644
index 00000000..57a1fd92
--- /dev/null
+++ b/API.md
@@ -0,0 +1,1285 @@
+# Karnak REST API Reference
+
+This document covers every REST API endpoint exposed by Karnak, with full request/response details and `curl` examples.
+
+**Base URL:** `http://localhost:8081`
+**Default credentials:** `admin` / `karnak`
+**Authentication:** HTTP Basic or Spring form-login session cookie (all endpoints except `/api/echo/destinations`).
+> With the default in-memory IdP this build accepts HTTP Basic on `/api/*`.
+> Form-login also works: POST credentials to `/login` once and carry the
+> resulting `JSESSIONID` cookie on every subsequent request. The Python
+> package in `python-client/` (`karnak-api-client`) handles both via
+> `KarnakClient` (auth_mode `auto`/`basic`/`form`).
+>
+> ```
+> POST /login
+> Content-Type: application/x-www-form-urlencoded
+>
+> username=admin&password=karnak
+> ```
+>
+> On success Karnak responds `302` to `/`; on failure `302` to `/login?error`.
+> The `curl` examples below use `--cookie-jar` / `--cookie` to replicate this:
+>
+> ```bash
+> # Obtain session cookie
+> curl -c /tmp/karnak.jar -X POST http://localhost:8081/login \
+> -d "username=admin&password=karnak"
+>
+> # Use the cookie for API calls
+> curl -b /tmp/karnak.jar http://localhost:8081/api/forward-nodes
+> ```
+>
+> When Karnak is started with `IDP=oidc`, the form-login endpoint uses OIDC
+> instead of in-memory credentials and these API calls can only be reached
+> through an authenticated OAuth2 browser session.
+
+---
+
+## Table of Contents
+
+1. [Echo](#1-echo)
+2. [Forward Nodes](#2-forward-nodes)
+3. [Source Nodes](#3-source-nodes)
+4. [Destinations](#4-destinations)
+5. [Profiles](#5-profiles)
+6. [Projects](#6-projects)
+7. [Project Secrets](#7-project-secrets)
+8. [External IDs](#8-external-ids)
+9. [Auth Configs](#9-auth-configs)
+10. [Monitoring](#10-monitoring)
+11. [End-to-end example](#11-end-to-end-example)
+
+---
+
+## 1. Echo
+
+Check the connectivity status of configured destinations for a given source AE Title. **No authentication required.**
+
+### `GET /api/echo/destinations`
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `srcAet` | query | yes | Source AE Title |
+
+**Response codes:** `200 OK`, `204 No Content`
+
+**Response body (200):**
+```xml
+
+
+ https://dicomweb.example.com/studies
+ 0
+
+
+ ARCHIVE
+ 0
+
+
+```
+
+> `status: 0` = reachable. Non-zero = unreachable or error.
+
+```bash
+# XML response (default)
+curl "http://localhost:8081/api/echo/destinations?srcAet=MY_GATEWAY"
+
+# JSON response
+curl -H "Accept: application/json" \
+ "http://localhost:8081/api/echo/destinations?srcAet=MY_GATEWAY"
+```
+
+---
+
+## 2. Forward Nodes
+
+A **Forward Node** represents a gateway entry point identified by an AE Title. DICOM senders target this AE Title to push images through Karnak.
+
+### `GET /api/forward-nodes`
+
+List all forward nodes.
+
+**Response codes:** `200 OK`, `204 No Content`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/forward-nodes
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "id": 1,
+ "fwdAeTitle": "MY_GATEWAY",
+ "fwdDescription": "Main gateway node",
+ "sourceNodes": [],
+ "destinationEntities": []
+ }
+]
+```
+
+---
+
+### `POST /api/forward-nodes`
+
+Create a new forward node.
+
+**Request body:**
+```json
+{
+ "fwdAeTitle": "MY_GATEWAY",
+ "fwdDescription": "Main gateway node"
+}
+```
+
+**Response codes:** `201 Created`, `400 Bad Request` (missing/blank `fwdAeTitle`), `409 Conflict` (AE Title already exists)
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes \
+ -H "Content-Type: application/json" \
+ -d '{"fwdAeTitle": "MY_GATEWAY", "fwdDescription": "Main gateway node"}'
+```
+
+---
+
+### `GET /api/forward-nodes/{id}`
+
+Get a forward node by ID.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/forward-nodes/1
+```
+
+---
+
+### `PUT /api/forward-nodes/{id}`
+
+Update a forward node. **Only `fwdAeTitle` and `fwdDescription` are updatable** —
+the server preserves existing source nodes and destinations even if they are
+absent (or empty) in the request body. To add/remove children, use the
+`/source-nodes` and `/destinations` sub-resources.
+
+**Request body:**
+```json
+{
+ "fwdAeTitle": "MY_GATEWAY",
+ "fwdDescription": "Updated description"
+}
+```
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X PUT http://localhost:8081/api/forward-nodes/1 \
+ -H "Content-Type: application/json" \
+ -d '{"fwdAeTitle": "MY_GATEWAY", "fwdDescription": "Updated description"}'
+```
+
+---
+
+### `DELETE /api/forward-nodes/{id}`
+
+Delete a forward node and all its source nodes and destinations.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1
+```
+
+---
+
+## 3. Source Nodes
+
+Source nodes restrict which DICOM senders are accepted by a forward node. If none are configured, all sources are accepted.
+
+### `GET /api/forward-nodes/{id}/source-nodes`
+
+List source nodes for a forward node.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/forward-nodes/1/source-nodes
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "id": 5,
+ "description": "Main PACS",
+ "aeTitle": "PACS_SRC",
+ "hostname": "192.168.1.10",
+ "checkHostname": true
+ }
+]
+```
+
+---
+
+### `POST /api/forward-nodes/{id}/source-nodes`
+
+Add a source node.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `aeTitle` | string | yes | AE Title (max 16 chars) |
+| `hostname` | string | no | IP or hostname |
+| `checkHostname` | boolean | no | Enforce hostname check (default: false) |
+| `description` | string | no | Free text description |
+
+**Response codes:** `201 Created`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/source-nodes \
+ -H "Content-Type: application/json" \
+ -d '{
+ "aeTitle": "PACS_SRC",
+ "hostname": "192.168.1.10",
+ "checkHostname": true,
+ "description": "Main PACS"
+ }'
+```
+
+---
+
+### `DELETE /api/forward-nodes/{id}/source-nodes/{sourceNodeId}`
+
+Remove a source node.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1/source-nodes/5
+```
+
+---
+
+## 4. Destinations
+
+A destination is a forwarding target — either a **DICOM node** (`type: dicom`) or a **DICOMWeb STOW-RS endpoint** (`type: stow`).
+
+### `GET /api/forward-nodes/{id}/destinations`
+
+List all destinations of a forward node.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/forward-nodes/1/destinations
+```
+
+---
+
+### `POST /api/forward-nodes/{id}/destinations`
+
+Add a destination.
+
+#### DICOM destination
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `destinationType` | string | yes | Must be `"dicom"` |
+| `aeTitle` | string | yes | Destination AE Title (max 16 chars) |
+| `hostname` | string | yes | Destination host/IP |
+| `port` | integer | yes | Port (1–65535) |
+| `description` | string | no | Free text description |
+| `activate` | boolean | no | Enable/disable (default: true) |
+| `condition` | string | no | DICOM expression filter |
+| `useaetdest` | boolean | no | Use destination AET as calling AET |
+| `desidentification` | boolean | no | Enable de-identification |
+| `deIdentificationProject` | object | no | `{"id": N}` — project to use for de-identification |
+| `pseudonymType` | string | no | How the external pseudonym is resolved when de-identification is enabled (default: `CACHE_EXTID`). See [Pseudonym type](#pseudonym-type-de-identification) below. |
+| `issuerByDefault` | string | no | Default Issuer of Patient ID used with Patient ID for unique identification |
+| `tag` | string | no | DICOM tag (8 hex digits, e.g. `00100010`) — required for `EXTID_IN_TAG` |
+| `delimiter` | string | no | Delimiter to split tag value — required when `position` > 0 for `EXTID_IN_TAG` |
+| `position` | integer | no | Zero-based index after split — required when `delimiter` is set for `EXTID_IN_TAG` |
+| `savePseudonym` | boolean | no | Store pseudonym read from DICOM tag in cache (`EXTID_IN_TAG` only) |
+| `pseudonymUrl` | string | no | External API URL (DICOM expressions allowed) — required for `EXTID_API` |
+| `method` | string | no | `GET` or `POST` — required for `EXTID_API` |
+| `responsePath` | string | no | JSON path to pseudonym in API response — required for `EXTID_API` |
+| `body` | string | no | JSON request body — required for `EXTID_API` when `method` is `POST` |
+| `authConfig` | string | no | Auth config code (from `/api/auth-configs`) for `EXTID_API` |
+| `activateTagMorphing` | boolean | no | Enable tag morphing |
+| `tagMorphingProject` | object | no | `{"id": N}` — project for tag morphing |
+| `activateNotification` | boolean | no | Enable email notifications |
+| `notify` | string | no | Comma-separated email list |
+| `notifyObjectPattern` | string | no | Email subject pattern |
+| `notifyObjectValues` | string | no | DICOM tag values to embed in subject |
+| `notifyInterval` | integer | no | Notification delay in seconds |
+| `filterBySOPClasses` | boolean | no | Enable SOP class filtering |
+| `transferSyntax` | string | no | Transfer syntax UID |
+| `transcodeOnlyUncompressed` | boolean | no | Only transcode uncompressed images |
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "description": "Long-term archive",
+ "aeTitle": "ARCHIVE",
+ "hostname": "192.168.1.20",
+ "port": 11112,
+ "activate": true
+ }'
+```
+
+#### DICOM destination with de-identification
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "aeTitle": "ARCHIVE_ANON",
+ "hostname": "192.168.1.20",
+ "port": 11112,
+ "activate": true,
+ "desidentification": true,
+ "deIdentificationProject": {"id": 1},
+ "activateNotification": true,
+ "notify": "admin@hospital.org",
+ "notifyObjectPattern": "[Karnak] %s %.30s",
+ "notifyObjectValues": "PatientID,StudyDescription",
+ "notifyInterval": 45
+ }'
+```
+
+#### Pseudonym type (de-identification)
+
+When `desidentification` is `true`, `pseudonymType` selects how Karnak obtains the **external pseudonym** before applying the project profile. Use the **enum name** in JSON (not the UI label).
+
+| Value | Description | Additional fields |
+|-------|-------------|-------------------|
+| `CACHE_EXTID` | Lookup in the project's external-ID cache (default) | Pre-load mappings via `POST /api/projects/{id}/external-ids` |
+| `EXTID_IN_TAG` | Read from a DICOM tag (optional split by delimiter) | `tag`, optional `delimiter` + `position`, optional `savePseudonym` |
+| `EXTID_API` | HTTP call to an external service | `pseudonymUrl`, `method`, `responsePath`, optional `body` (POST), optional `authConfig` |
+
+**DICOM destination — pseudonym from cache (default):**
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "aeTitle": "ARCHIVE_ANON",
+ "hostname": "192.168.1.20",
+ "port": 11112,
+ "activate": true,
+ "desidentification": true,
+ "deIdentificationProject": {"id": 1},
+ "pseudonymType": "CACHE_EXTID"
+ }'
+```
+
+**DICOM destination — pseudonym in a DICOM tag:**
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "aeTitle": "ARCHIVE_ANON",
+ "hostname": "192.168.1.20",
+ "port": 11112,
+ "activate": true,
+ "desidentification": true,
+ "deIdentificationProject": {"id": 1},
+ "pseudonymType": "EXTID_IN_TAG",
+ "tag": "00100010",
+ "delimiter": "^",
+ "position": 0
+ }'
+```
+
+**DICOM destination — pseudonym from external API:**
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "aeTitle": "ARCHIVE_ANON",
+ "hostname": "192.168.1.20",
+ "port": 11112,
+ "activate": true,
+ "desidentification": true,
+ "deIdentificationProject": {"id": 1},
+ "pseudonymType": "EXTID_API",
+ "pseudonymUrl": "https://pseudonym.example.com/lookup?patientId={PatientID}",
+ "method": "GET",
+ "responsePath": "$.pseudonym"
+ }'
+```
+
+To change `pseudonymType` on an existing destination, `GET` the destination, update the field (and type-specific fields), then `PUT` the full object to `/api/forward-nodes/{id}/destinations/{destinationId}`.
+
+#### STOW-RS destination
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `destinationType` | string | yes | Must be `"stow"` |
+| `url` | string | yes | STOW-RS endpoint URL |
+| `description` | string | no | Free text description |
+| `headers` | string | no | HTTP headers (max 4096 chars) |
+| `activate` | boolean | no | Enable/disable (default: true) |
+| `condition` | string | no | DICOM expression filter |
+| `authConfig` | string | no | Auth config code (from `/api/auth-configs`) |
+| `desidentification` | boolean | no | Enable de-identification |
+| `deIdentificationProject` | object | no | `{"id": N}` |
+| `pseudonymType` | string | no | Same as DICOM — see [Pseudonym type](#pseudonym-type-de-identification) |
+| `issuerByDefault` | string | no | Default Issuer of Patient ID |
+| `tag` | string | no | For `EXTID_IN_TAG` |
+| `delimiter` | string | no | For `EXTID_IN_TAG` |
+| `position` | integer | no | For `EXTID_IN_TAG` |
+| `savePseudonym` | boolean | no | For `EXTID_IN_TAG` |
+| `pseudonymUrl` | string | no | For `EXTID_API` |
+| `method` | string | no | For `EXTID_API` |
+| `responsePath` | string | no | For `EXTID_API` |
+| `body` | string | no | For `EXTID_API` (POST) |
+| `authConfig` | string | no | For `EXTID_API` or STOW OAuth |
+| `activateTagMorphing` | boolean | no | Enable tag morphing |
+| `tagMorphingProject` | object | no | `{"id": N}` |
+| `activateNotification` | boolean | no | Enable email notifications |
+| `notify` | string | no | Comma-separated email list |
+| `filterBySOPClasses` | boolean | no | Enable SOP class filtering |
+| `transferSyntax` | string | no | Transfer syntax UID |
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "stow",
+ "description": "Cloud DICOMWeb endpoint",
+ "url": "https://dicomweb.example.com/wado/rs/studies",
+ "headers": "Authorization: Bearer eyJhbGc...",
+ "activate": true
+ }'
+```
+
+#### STOW-RS with OAuth2 auth config
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/forward-nodes/1/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "stow",
+ "url": "https://dicomweb.example.com/wado/rs/studies",
+ "activate": true,
+ "authConfig": "keycloak-prod"
+ }'
+```
+
+**Response codes:** `201 Created`, `404 Not Found`
+
+---
+
+### `PUT /api/forward-nodes/{id}/destinations/{destinationId}`
+
+Update a destination. Send the full destination object.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X PUT http://localhost:8081/api/forward-nodes/1/destinations/3 \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "dicom",
+ "aeTitle": "ARCHIVE",
+ "hostname": "192.168.1.20",
+ "port": 11113,
+ "activate": false
+ }'
+```
+
+---
+
+### `DELETE /api/forward-nodes/{id}/destinations/{destinationId}`
+
+Delete a destination.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/forward-nodes/1/destinations/3
+```
+
+---
+
+## 5. Profiles
+
+De-identification profiles define which DICOM tags to anonymize and how. They are uploaded as YAML files and then assigned to projects.
+
+### `GET /api/profiles`
+
+List all profiles (summary: id, name, version, minimumKarnakVersion, byDefault).
+
+**Response codes:** `200 OK`, `204 No Content`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/profiles
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "id": 1,
+ "name": "Profile by Default",
+ "version": "1.0",
+ "minimumKarnakVersion": "0.9.7",
+ "byDefault": true
+ },
+ {
+ "id": 3,
+ "name": "My Custom Profile",
+ "version": "2.1",
+ "minimumKarnakVersion": "1.0.0",
+ "byDefault": false
+ }
+]
+```
+
+---
+
+### `POST /api/profiles`
+
+Upload a YAML profile file. The file is validated before saving.
+
+**Content-Type:** `multipart/form-data`
+**Form field:** `file` — the YAML file
+
+**Response codes:**
+- `201 Created` — profile saved
+- `400 Bad Request` — unreadable file or invalid YAML syntax
+- `422 Unprocessable Entity` — YAML parsed but profile element validation failed (body contains error list)
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/profiles \
+ -F "file=@my-deidentification-profile.yml"
+```
+
+**Response body (201):**
+```json
+{"id": 3, "name": "My Custom Profile"}
+```
+
+**Response body (422):**
+```json
+{
+ "errors": [
+ "Tag replacement: Cannot find the profile codename: action.on.specific.tags"
+ ]
+}
+```
+
+**Example YAML profile structure:**
+```yaml
+name: My Custom Profile
+version: "1.0"
+minimumKarnakVersion: "1.0.0"
+profileElements:
+ - name: "Remove patient name"
+ codename: "action.on.specific.tags"
+ action: "X"
+ tags:
+ - "(0010,0010)"
+ - name: "Keep study date"
+ codename: "action.on.specific.tags"
+ action: "K"
+ tags:
+ - "(0008,0020)"
+```
+
+---
+
+### `GET /api/profiles/{id}`
+
+Get full profile details as JSON.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/profiles/3
+```
+
+---
+
+### `GET /api/profiles/{id}/download`
+
+Download the profile as a YAML file (suitable for re-upload).
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+# Save to file
+curl -u admin:karnak http://localhost:8081/api/profiles/3/download -o my-profile.yml
+
+# Print to stdout
+curl -u admin:karnak http://localhost:8081/api/profiles/3/download
+```
+
+---
+
+### `PUT /api/profiles/{id}`
+
+Update profile metadata only (does not affect profile elements/masks).
+
+**Request body (all fields optional):**
+```json
+{
+ "name": "Renamed Profile",
+ "version": "2.0",
+ "minimumKarnakVersion": "1.1.0"
+}
+```
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X PUT http://localhost:8081/api/profiles/3 \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Renamed Profile", "version": "2.0"}'
+```
+
+---
+
+### `DELETE /api/profiles/{id}`
+
+Delete a profile. Will fail at DB level if the profile is still referenced by a project.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/profiles/3
+```
+
+---
+
+## 6. Projects
+
+Projects group a de-identification profile with an HMAC secret key. They are assigned to destinations to enable de-identification or tag morphing.
+
+### `GET /api/projects`
+
+List all projects.
+
+**Response codes:** `200 OK`, `204 No Content`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/projects
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "id": 1,
+ "name": "Study Group A",
+ "profileEntity": {
+ "name": "My Custom Profile",
+ "version": "1.0"
+ },
+ "secretEntities": [
+ {
+ "id": 2,
+ "creationDate": "2024-06-01T10:30:00",
+ "active": true
+ }
+ ]
+ }
+]
+```
+
+> The raw HMAC key bytes are **never** included in this response. Capture the
+> key once at creation time from `POST /api/projects/{id}/secrets`.
+
+---
+
+### `POST /api/projects`
+
+Create a new project.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | yes | Project name |
+| `profileId` | long | no | ID of a profile to associate |
+
+**Response codes:** `201 Created`, `400 Bad Request` (missing name), `404 Not Found` (profile not found)
+
+```bash
+# Project without a profile
+curl -u admin:karnak -X POST http://localhost:8081/api/projects \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Study Group A"}'
+
+# Project with a profile
+curl -u admin:karnak -X POST http://localhost:8081/api/projects \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Study Group A", "profileId": 3}'
+```
+
+---
+
+### `GET /api/projects/{id}`
+
+Get a project by ID.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/projects/1
+```
+
+---
+
+### `PUT /api/projects/{id}`
+
+Update a project. All fields optional; only provided fields are changed.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `name` | string | New name |
+| `profileId` | long or null | New profile ID; send `null` to detach the profile |
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+# Rename and change profile
+curl -u admin:karnak -X PUT http://localhost:8081/api/projects/1 \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Study Group A v2", "profileId": 5}'
+
+# Detach profile
+curl -u admin:karnak -X PUT http://localhost:8081/api/projects/1 \
+ -H "Content-Type: application/json" \
+ -d '{"profileId": null}'
+```
+
+---
+
+### `DELETE /api/projects/{id}`
+
+Delete a project.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/projects/1
+```
+
+---
+
+## 7. Project Secrets
+
+Each project has an HMAC-SHA256 secret key used to pseudonymize patient
+identifiers. Only one secret is active at a time; adding a new one deactivates
+the previous.
+
+> **The full hex key is only returned in the `POST /secrets` response.** It is
+> never echoed back from `GET /api/projects/...`. Store the value safely on
+> create — there is no read-back endpoint.
+
+### `POST /api/projects/{id}/secrets`
+
+Add or generate an HMAC secret for the project.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `hexKey` | string | no | Exactly 32 hex characters (16 bytes). Omit for auto-generation. Dashes are stripped automatically; any other separators or non-hex characters are rejected with `400`. |
+
+**Response codes:** `201 Created`, `400 Bad Request` (invalid hex / wrong length), `404 Not Found`
+
+```bash
+# Auto-generate a random key
+curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \
+ -H "Content-Type: application/json" -d '{}'
+
+# Import a known key
+curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \
+ -H "Content-Type: application/json" \
+ -d '{"hexKey": "deadbeefdeadbeefdeadbeefdeadbeef"}'
+
+# Import a key in display format (dashes stripped automatically)
+curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/secrets \
+ -H "Content-Type: application/json" \
+ -d '{"hexKey": "deadbeef-dead-beef-dead-beefdeadbeef"}'
+```
+
+**Response body (201):**
+```json
+{
+ "projectId": 1,
+ "hexKey": "deadbeefdeadbeefdeadbeefdeadbeef",
+ "displayKey": "deadbeef-dead-beef-dead-beefdeadbeef",
+ "active": true
+}
+```
+
+---
+
+## 8. External IDs
+
+The External ID cache maps real patient identifiers to pseudonyms, enabling lookup-based de-identification. **Data is in-memory — it is lost on application restart.** It is scoped per project.
+
+### `GET /api/projects/{id}/external-ids`
+
+List all patient mappings for a project.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/projects/1/external-ids
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "pseudonym": "PSEUDO-001",
+ "patientId": "PAT-12345",
+ "patientFirstName": "John",
+ "patientLastName": "Doe",
+ "patientName": "Doe^John",
+ "patientBirthDate": "1980-05-15",
+ "patientSex": "M",
+ "issuerOfPatientId": "HospitalA",
+ "projectID": 1
+ }
+]
+```
+
+---
+
+### `POST /api/projects/{id}/external-ids`
+
+Add a single patient mapping.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `pseudonym` | string | yes | Anonymized identifier |
+| `patientId` | string | yes | Real patient ID |
+| `patientFirstName` | string | no | First name |
+| `patientLastName` | string | no | Last name |
+| `issuerOfPatientId` | string | no | Issuer of patient ID (used as part of cache key) |
+| `patientBirthDate` | string | no | Format: `YYYY-MM-DD` |
+| `patientSex` | string | no | `M`, `F`, or `O` |
+
+**Response codes:** `201 Created`, `400 Bad Request` (missing pseudonym/patientId or malformed `patientBirthDate`), `404 Not Found`, `409 Conflict` (patient already exists)
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/external-ids \
+ -H "Content-Type: application/json" \
+ -d '{
+ "pseudonym": "PSEUDO-001",
+ "patientId": "PAT-12345",
+ "patientFirstName": "John",
+ "patientLastName": "Doe",
+ "issuerOfPatientId": "HospitalA",
+ "patientBirthDate": "1980-05-15",
+ "patientSex": "M"
+ }'
+```
+
+---
+
+### `POST /api/projects/{id}/external-ids/import`
+
+Bulk import patient mappings from a JSON array. Duplicate entries (same `patientId` + `issuerOfPatientId`) are skipped.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/projects/1/external-ids/import \
+ -H "Content-Type: application/json" \
+ -d '[
+ {
+ "pseudonym": "P001",
+ "patientId": "ID001",
+ "patientFirstName": "Alice",
+ "patientLastName": "Smith",
+ "issuerOfPatientId": "HospitalA"
+ },
+ {
+ "pseudonym": "P002",
+ "patientId": "ID002",
+ "patientFirstName": "Bob",
+ "patientLastName": "Jones",
+ "issuerOfPatientId": "HospitalA"
+ }
+ ]'
+```
+
+**Response body (200):**
+```json
+{"added": 2, "skipped": 0}
+```
+
+---
+
+### `PUT /api/projects/{id}/external-ids/{patientId}`
+
+Update an existing patient mapping. The original record is identified by `{patientId}` (path) and `issuerId` (query param). Only fields present in the body are updated.
+
+| Query param | Default | Description |
+|-------------|---------|-------------|
+| `issuerId` | `""` | Issuer of patient ID (part of the cache lookup key) |
+
+**Response codes:** `200 OK`, `400 Bad Request` (malformed `patientBirthDate`), `404 Not Found`
+
+```bash
+curl -u admin:karnak \
+ -X PUT "http://localhost:8081/api/projects/1/external-ids/PAT-12345?issuerId=HospitalA" \
+ -H "Content-Type: application/json" \
+ -d '{"patientFirstName": "Jonathan", "patientSex": "M"}'
+```
+
+---
+
+### `DELETE /api/projects/{id}/external-ids/{patientId}`
+
+Delete a single patient mapping.
+
+| Query param | Default | Description |
+|-------------|---------|-------------|
+| `issuerId` | `""` | Issuer of patient ID |
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak \
+ -X DELETE "http://localhost:8081/api/projects/1/external-ids/PAT-12345?issuerId=HospitalA"
+```
+
+---
+
+### `DELETE /api/projects/{id}/external-ids`
+
+Delete **all** patient mappings for a project.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/projects/1/external-ids
+```
+
+---
+
+## 9. Auth Configs
+
+OAuth2 authentication configurations are used by STOW-RS destinations to obtain bearer tokens automatically. Sensitive fields (`clientId`, `clientSecret`, `accessTokenUrl`, `scope`) are encrypted at rest in the database.
+
+### `GET /api/auth-configs`
+
+List all auth configurations.
+
+**Response codes:** `200 OK`, `204 No Content`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/auth-configs
+```
+
+**Response body (200):**
+```json
+[
+ {
+ "code": "keycloak-prod",
+ "clientId": "karnak-client",
+ "accessTokenUrl": "https://auth.example.com/realms/hospital/protocol/openid-connect/token",
+ "scope": "openid",
+ "authConfigType": "OAUTH2"
+ }
+]
+```
+
+> `clientSecret` is **never** returned in responses (write-only). It can only be
+> set via `POST` or `PUT` and is encrypted at rest. There is no read-back of an
+> existing secret.
+
+---
+
+### `POST /api/auth-configs`
+
+Create a new auth configuration.
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `code` | string | yes | Unique identifier (referenced in destinations) |
+| `clientId` | string | no | OAuth2 client ID |
+| `clientSecret` | string | no | OAuth2 client secret (write-only; never returned by GET) |
+| `accessTokenUrl` | string | no | Token endpoint URL |
+| `scope` | string | no | OAuth2 scope |
+| `authConfigType` | string | no | Auth type — only `"OAUTH2"` supported (default) |
+
+**Response codes:** `201 Created`, `400 Bad Request` (missing `code`), `409 Conflict` (code already exists)
+
+```bash
+curl -u admin:karnak -X POST http://localhost:8081/api/auth-configs \
+ -H "Content-Type: application/json" \
+ -d '{
+ "code": "keycloak-prod",
+ "clientId": "karnak-client",
+ "clientSecret": "s3cr3t-client-p@ssword",
+ "accessTokenUrl": "https://auth.example.com/realms/hospital/protocol/openid-connect/token",
+ "scope": "openid"
+ }'
+```
+
+---
+
+### `GET /api/auth-configs/{code}`
+
+Get an auth config by its code identifier.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak http://localhost:8081/api/auth-configs/keycloak-prod
+```
+
+---
+
+### `PUT /api/auth-configs/{code}`
+
+Update an existing auth config. Only provided fields are changed.
+
+**Response codes:** `200 OK`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X PUT http://localhost:8081/api/auth-configs/keycloak-prod \
+ -H "Content-Type: application/json" \
+ -d '{
+ "clientSecret": "new-s3cr3t",
+ "scope": "openid profile"
+ }'
+```
+
+---
+
+### `DELETE /api/auth-configs/{code}`
+
+Delete an auth config.
+
+**Response codes:** `204 No Content`, `404 Not Found`
+
+```bash
+curl -u admin:karnak -X DELETE http://localhost:8081/api/auth-configs/keycloak-prod
+```
+
+---
+
+## 10. Monitoring
+
+Query, count, and export aggregated DICOM transfer status records logged by Karnak. Each
+record is one row per (forward node, destination, series) — counters (`instances`,
+`retries`, `sent`, `errors`, `excluded`) accumulate as instances are transferred, rather
+than one row per individual transfer event.
+
+### `GET /api/monitoring/transfers`
+
+Query transfer records with optional filters and pagination.
+
+| Query param | Type | Default | Description |
+|-------------|------|---------|-------------|
+| `studyUid` | string | — | Filter by Study Instance UID (partial match) |
+| `serieUid` | string | — | Filter by Series Instance UID (partial match) |
+| `status` | string | `ALL` | One of: `ALL`, `SENT`, `NOT_SENT`, `EXCLUDED`, `ERROR` |
+| `start` | ISO datetime | — | From date-time (e.g. `2024-01-01T00:00:00`), applies to the series' last activity |
+| `end` | ISO datetime | — | To date-time (e.g. `2024-12-31T23:59:59`), applies to the series' last activity |
+| `page` | integer | `0` | Page number (0-based). Negative values are clamped to `0`. |
+| `size` | integer | `50` | Records per page (1–1000). Out-of-range values are clamped. |
+
+**Response codes:** `200 OK`
+
+```bash
+# All records, page 0
+curl -u admin:karnak http://localhost:8081/api/monitoring/transfers
+
+# Filter by status
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers?status=ERROR"
+
+# Filter by date range
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers?start=2024-06-01T00:00:00&end=2024-06-30T23:59:59&size=100"
+
+# Filter by Study UID
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers?studyUid=1.2.840.10008"
+```
+
+**Response body (200):**
+```json
+{
+ "content": [
+ {
+ "forwardNodeId": 1,
+ "destinationId": 3,
+ "studyUidOriginal": "1.2.840.10008.5.1.4.1.1.4",
+ "serieUidOriginal": "...",
+ "instances": 42,
+ "retries": 0,
+ "sent": 42,
+ "errors": 0,
+ "excluded": 0,
+ "firstSeen": "2024-06-15T14:30:00",
+ "lastSeen": "2024-06-15T14:32:00"
+ }
+ ],
+ "totalElements": 1543,
+ "totalPages": 31,
+ "page": 0,
+ "size": 50
+}
+```
+
+---
+
+### `GET /api/monitoring/transfers/count`
+
+Count transfer records matching the filter. Accepts the same filter params as the query endpoint (except `page` and `size`).
+
+**Response codes:** `200 OK`
+
+```bash
+# Count all errors
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers/count?status=ERROR"
+```
+
+**Response body (200):**
+```json
+{"count": 42}
+```
+
+---
+
+### `GET /api/monitoring/transfers/export`
+
+Download transfer records as a CSV file. Accepts the same filter params as the query endpoint, plus:
+
+| Query param | Default | Description |
+|-------------|---------|-------------|
+| `delimiter` | `,` | CSV column separator character |
+
+**Response codes:** `200 OK`, `500 Internal Server Error`
+
+```bash
+# Export all records as CSV
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers/export" \
+ -o monitoring.csv
+
+# Export errors only, semicolon delimiter
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers/export?status=ERROR&delimiter=;" \
+ -o errors.csv
+
+# Export a specific date range
+curl -u admin:karnak \
+ "http://localhost:8081/api/monitoring/transfers/export?start=2024-06-01T00:00:00&end=2024-06-30T23:59:59" \
+ -o june-2024.csv
+```
+
+---
+
+## 11. End-to-end example
+
+This script sets up a complete Karnak configuration programmatically — no UI needed.
+
+```bash
+#!/bin/bash
+BASE="http://localhost:8081"
+CREDS="admin:karnak"
+
+echo "=== 1. Upload a de-identification profile ==="
+PROFILE_ID=$(curl -s -u $CREDS -X POST $BASE/api/profiles \
+ -F "file=@deidentification.yml" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
+echo "Profile ID: $PROFILE_ID"
+
+echo "=== 2. Create a project and link the profile ==="
+PROJECT=$(curl -s -u $CREDS -X POST $BASE/api/projects \
+ -H "Content-Type: application/json" \
+ -d "{\"name\": \"Clinical Trial 2024\", \"profileId\": $PROFILE_ID}")
+PROJECT_ID=$(echo $PROJECT | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
+echo "Project ID: $PROJECT_ID"
+
+echo "=== 3. Generate an HMAC secret for the project ==="
+SECRET=$(curl -s -u $CREDS -X POST $BASE/api/projects/$PROJECT_ID/secrets \
+ -H "Content-Type: application/json" -d '{}')
+echo "Secret: $(echo $SECRET | python3 -c "import sys,json; print(json.load(sys.stdin)['displayKey'])")"
+
+echo "=== 4. Create an OAuth2 auth config for the STOW destination ==="
+curl -s -u $CREDS -X POST $BASE/api/auth-configs \
+ -H "Content-Type: application/json" \
+ -d '{
+ "code": "keycloak-prod",
+ "clientId": "karnak-client",
+ "clientSecret": "s3cr3t",
+ "accessTokenUrl": "https://auth.example.com/token",
+ "scope": "openid"
+ }' > /dev/null
+
+echo "=== 5. Create a forward node (gateway entry point) ==="
+NODE=$(curl -s -u $CREDS -X POST $BASE/api/forward-nodes \
+ -H "Content-Type: application/json" \
+ -d '{"fwdAeTitle": "GATEWAY1", "fwdDescription": "Clinical trial gateway"}')
+NODE_ID=$(echo $NODE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
+echo "Forward node ID: $NODE_ID"
+
+echo "=== 6. Restrict accepted sources (optional) ==="
+curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/source-nodes \
+ -H "Content-Type: application/json" \
+ -d '{
+ "aeTitle": "MODALITY1",
+ "hostname": "10.0.0.5",
+ "checkHostname": true,
+ "description": "MRI scanner"
+ }' > /dev/null
+
+echo "=== 7. Add a DICOM destination with de-identification ==="
+curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/destinations \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"destinationType\": \"dicom\",
+ \"description\": \"Anonymized archive\",
+ \"aeTitle\": \"PACS_ANON\",
+ \"hostname\": \"192.168.1.50\",
+ \"port\": 11112,
+ \"activate\": true,
+ \"desidentification\": true,
+ \"deIdentificationProject\": {\"id\": $PROJECT_ID},
+ \"pseudonymType\": \"CACHE_EXTID\"
+ }" > /dev/null
+
+echo "=== 8. Add a STOW-RS destination with OAuth2 ==="
+curl -s -u $CREDS -X POST $BASE/api/forward-nodes/$NODE_ID/destinations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "destinationType": "stow",
+ "description": "Cloud DICOMWeb archive",
+ "url": "https://dicomweb.example.com/wado/rs/studies",
+ "activate": true,
+ "authConfig": "keycloak-prod"
+ }' > /dev/null
+
+echo "=== 9. Pre-load external ID mappings ==="
+curl -s -u $CREDS -X POST $BASE/api/projects/$PROJECT_ID/external-ids/import \
+ -H "Content-Type: application/json" \
+ -d '[
+ {"pseudonym":"P001","patientId":"ID001","patientFirstName":"Alice","patientLastName":"Smith","issuerOfPatientId":"HospitalA"},
+ {"pseudonym":"P002","patientId":"ID002","patientFirstName":"Bob","patientLastName":"Jones","issuerOfPatientId":"HospitalA"}
+ ]' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Imported: {d[\"added\"]} added, {d[\"skipped\"]} skipped')"
+
+echo "=== 10. Check echo status ==="
+curl -s "http://localhost:8081/api/echo/destinations?srcAet=GATEWAY1"
+
+echo ""
+echo "=== Setup complete ==="
+echo "Forward node ID : $NODE_ID (AET: GATEWAY1)"
+echo "Project ID : $PROJECT_ID"
+echo "Profile ID : $PROFILE_ID"
+```
diff --git a/pom.xml b/pom.xml
index 643b609c..55bee832 100644
--- a/pom.xml
+++ b/pom.xml
@@ -191,6 +191,7 @@
maven-compiler-plugin
${java.version}
+ true
org.projectlombok
diff --git a/python-client/.gitignore b/python-client/.gitignore
new file mode 100644
index 00000000..f0ec10c9
--- /dev/null
+++ b/python-client/.gitignore
@@ -0,0 +1,5 @@
+*.egg-info/
+__pycache__/
+dist/
+build/
+.pytest_cache/
diff --git a/python-client/README.md b/python-client/README.md
new file mode 100644
index 00000000..1aabaf74
--- /dev/null
+++ b/python-client/README.md
@@ -0,0 +1,53 @@
+# karnak-api-client
+
+Python client for the REST API exposed by this Karnak fork
+([API.md](../API.md) documents every endpoint).
+
+Two layers:
+
+- `karnak_api_client.client` — `KarnakClient`: thin authenticated transport
+ mirroring the REST resources. Supports three auth modes:
+ - `basic` — HTTP Basic on every request (default in-memory IdP enables it);
+ - `form` — Spring form-login: POST `/login`, reuse the `JSESSIONID` cookie;
+ - `auto` (default) — try Basic, fall back to form-login transparently when
+ the server redirects to `/login` or returns 401.
+- `karnak_api_client.apply` — `apply_config(client, config)`: idempotent
+ desired-state application (profile, project + HMAC secret, auth configs,
+ forward node, source nodes, destinations, external-ID import) from a JSON
+ config document.
+
+## Install
+
+```sh
+pip install "karnak-api-client @ git+https://github.com/jbardet/karnak.git@client-v0.1.0#subdirectory=python-client"
+```
+
+## Usage
+
+```python
+from karnak_api_client import KarnakClient, KarnakClientConfig
+
+client = KarnakClient(KarnakClientConfig(
+ base_url="http://localhost:8081", username="admin", password="karnak",
+))
+profiles = client.list_profiles()
+```
+
+Or configure from the environment (`KARNAK_API_BASE`, `KARNAK_USERNAME`,
+`KARNAK_PASSWORD`, `KARNAK_AUTH_MODE`, `KARNAK_API_TIMEOUT_SEC`):
+
+```python
+client = KarnakClient() # KarnakClientConfig.from_env()
+```
+
+## Versioning
+
+Tag scheme `client-v` on this repo. The Docker image of the fork uses
+`-api.` tags; the two are released independently but the
+client tracks the controllers in `src/main/java/org/karnak/backend/controller/`.
+
+## Consumers
+
+- MiCo-BID-pipeline (DAG step `00_apply_karnak_config.py`, `micobid` CLI)
+- TMLCTP equivalence harness (`integration_tests/karnak_api/`)
+- `setup_deid_gateway.py` in this repo
diff --git a/python-client/karnak_api_client/__init__.py b/python-client/karnak_api_client/__init__.py
new file mode 100644
index 00000000..c5103e3b
--- /dev/null
+++ b/python-client/karnak_api_client/__init__.py
@@ -0,0 +1,38 @@
+"""Python client for the Karnak REST API (karnak-endpoints fork).
+
+Transport client (``client``) plus idempotent desired-state apply
+(``apply``). See API.md at the repository root for endpoint details.
+"""
+
+from .apply import (
+ KarnakConfigError,
+ apply_config,
+ load_apply_config,
+ profile_meta_from_yaml,
+ write_json_atomic,
+)
+from .client import (
+ ApiError,
+ AuthenticationError,
+ KarnakApiError,
+ KarnakClient,
+ KarnakClientConfig,
+ KarnakError,
+)
+
+__version__ = "0.1.0"
+
+__all__ = [
+ "ApiError",
+ "AuthenticationError",
+ "KarnakApiError",
+ "KarnakClient",
+ "KarnakClientConfig",
+ "KarnakConfigError",
+ "KarnakError",
+ "apply_config",
+ "load_apply_config",
+ "profile_meta_from_yaml",
+ "write_json_atomic",
+ "__version__",
+]
diff --git a/python-client/karnak_api_client/apply.py b/python-client/karnak_api_client/apply.py
new file mode 100644
index 00000000..a4c3c1d4
--- /dev/null
+++ b/python-client/karnak_api_client/apply.py
@@ -0,0 +1,367 @@
+"""Idempotent desired-state application for Karnak REST resources.
+
+``apply_config(client, config)`` takes a JSON-compatible config document
+describing the desired profile, project (+ HMAC secret), auth configs,
+forward node, source nodes, destinations and external-ID imports, and makes
+the minimum API calls needed to converge Karnak onto that state.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from .client import KarnakClient
+
+
+class KarnakConfigError(ValueError):
+ """Raised when a desired Karnak config cannot be built or validated."""
+
+
+def profile_meta_from_yaml(path: Path) -> dict[str, str]:
+ """Extract simple top-level YAML name/version without adding PyYAML."""
+ meta: dict[str, str] = {}
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except OSError as exc:
+ raise KarnakConfigError(f"Cannot read profile YAML {path}: {exc}") from exc
+
+ for line in lines:
+ match = re.match(r"^(name|version):\s*[\"']?([^\"'#]+)", line)
+ if match:
+ meta[match.group(1)] = match.group(2).strip()
+ if "name" in meta and "version" in meta:
+ break
+ return meta
+
+
+def load_apply_config(path: Path) -> dict[str, Any]:
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise KarnakConfigError(f"Cannot read JSON config {path}: {exc}") from exc
+ if not isinstance(raw, dict):
+ raise KarnakConfigError("Karnak config must be a JSON object")
+ return raw
+
+
+def write_json_atomic(path: Path, body: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(path.name + ".tmp")
+ tmp.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ tmp.replace(path)
+
+
+def _require_object(config: dict[str, Any], key: str) -> dict[str, Any]:
+ value = config.get(key)
+ if not isinstance(value, dict):
+ raise KarnakConfigError(f"config.{key} is required")
+ return value
+
+
+def _require_list(config: dict[str, Any], key: str) -> list[Any]:
+ value = config.get(key) or []
+ if not isinstance(value, list):
+ raise KarnakConfigError(f"{key} must be a list")
+ return value
+
+
+def _load_external_ids(config: dict[str, Any]) -> list[dict[str, Any]]:
+ rows = _require_list(config, "externalIds")
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ raise KarnakConfigError("externalIds entries must be objects")
+ out.append(row)
+
+ external_ids_path = config.get("externalIdsPath")
+ if external_ids_path:
+ p = Path(str(external_ids_path))
+ try:
+ loaded = json.loads(p.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise KarnakConfigError(f"Cannot read externalIdsPath {p}: {exc}") from exc
+ if not isinstance(loaded, list):
+ raise KarnakConfigError("externalIdsPath must contain a JSON array")
+ for row in loaded:
+ if not isinstance(row, dict):
+ raise KarnakConfigError("externalIdsPath entries must be objects")
+ out.append(row)
+ return out
+
+
+def _find_profile(
+ client: KarnakClient,
+ *,
+ name: str,
+ version: str | None,
+) -> dict[str, Any] | None:
+ for item in client.list_profiles():
+ if str(item.get("name", "")) != name:
+ continue
+ if version is not None and str(item.get("version", "")) != version:
+ continue
+ return item
+ return None
+
+
+def _ensure_profile(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]:
+ profile_cfg = _require_object(config, "profile")
+ path_raw = profile_cfg.get("path")
+ if not path_raw:
+ raise KarnakConfigError("config.profile.path is required")
+ path = Path(str(path_raw))
+ if not path.is_file():
+ raise KarnakConfigError(f"Profile YAML does not exist: {path}")
+
+ yaml_meta = profile_meta_from_yaml(path)
+ name = str(profile_cfg.get("name") or yaml_meta.get("name") or "").strip()
+ version = str(profile_cfg.get("version") or yaml_meta.get("version") or "").strip() or None
+ if not name:
+ raise KarnakConfigError("config.profile.name is required when YAML name cannot be inferred")
+
+ force_upload = bool(profile_cfg.get("forceUpload", False))
+ if not force_upload:
+ existing = _find_profile(client, name=name, version=version)
+ if existing:
+ return {
+ "id": int(existing["id"]),
+ "name": existing.get("name", name),
+ "version": existing.get("version", version),
+ "created": False,
+ }
+
+ created = client.upload_profile(path)
+ return {
+ "id": int(created["id"]),
+ "name": created.get("name", name),
+ "version": version,
+ "created": True,
+ }
+
+
+def _find_project(client: KarnakClient, name: str) -> dict[str, Any] | None:
+ for item in client.list_projects():
+ if str(item.get("name", "")) == name:
+ return item
+ return None
+
+
+def _project_has_active_secret(project: dict[str, Any]) -> bool:
+ secrets = project.get("secretEntities") or []
+ if not isinstance(secrets, list):
+ return False
+ return any(bool(s.get("active")) for s in secrets if isinstance(s, dict))
+
+
+def _ensure_project(
+ client: KarnakClient,
+ config: dict[str, Any],
+ profile_id: int,
+) -> dict[str, Any]:
+ project_cfg = _require_object(config, "project")
+ name = str(project_cfg.get("name", "")).strip()
+ if not name:
+ raise KarnakConfigError("config.project.name is required")
+
+ existing = _find_project(client, name)
+ if existing:
+ project_id = int(existing["id"])
+ # Skip PUT if the existing project's linked profile already matches by name.
+ # GET /api/projects responses don't include profileEntity.id, so compare
+ # by name+version which is what _ensure_profile guarantees stable on.
+ pe = existing.get("profileEntity") or {}
+ profile_cfg = _require_object(config, "profile")
+ desired_name = str(profile_cfg.get("name", "")).strip()
+ desired_version = str(profile_cfg.get("version", "")).strip()
+ already_matches = (
+ str(pe.get("name", "")).strip() == desired_name
+ and str(pe.get("version", "")).strip() == desired_version
+ )
+ if not already_matches:
+ client.update_project(project_id, profile_id=profile_id)
+ created = False
+ else:
+ project = client.create_project(name, profile_id=profile_id)
+ project_id = int(project["id"])
+ created = True
+
+ project = client.get_project(project_id)
+ secret_created = False
+ secret_result: dict[str, Any] | None = None
+ ensure_secret = bool(project_cfg.get("ensureSecret", True))
+ secret_hex = project_cfg.get("secretHex")
+ if secret_hex or (ensure_secret and not _project_has_active_secret(project)):
+ secret_result = client.add_project_secret(project_id, str(secret_hex) if secret_hex else None)
+ secret_created = True
+
+ summary: dict[str, Any] = {
+ "id": project_id,
+ "name": name,
+ "created": created,
+ "secret_created": secret_created,
+ }
+ if secret_result:
+ # Karnak only returns the raw key once. Store it in the apply summary
+ # for the operator, but callers must not write it to logs.
+ summary["secret"] = secret_result
+ return summary
+
+
+def _ensure_auth_configs(client: KarnakClient, config: dict[str, Any]) -> list[dict[str, Any]]:
+ requested = _require_list(config, "authConfigs")
+ existing_codes = {
+ str(item.get("code"))
+ for item in client.list_auth_configs()
+ if isinstance(item, dict) and item.get("code")
+ }
+ summary: list[dict[str, Any]] = []
+ for body in requested:
+ if not isinstance(body, dict):
+ raise KarnakConfigError("authConfigs entries must be objects")
+ code = str(body.get("code", "")).strip()
+ if not code:
+ raise KarnakConfigError("authConfigs[].code is required")
+ if code in existing_codes:
+ client.update_auth_config(code, body)
+ summary.append({"code": code, "created": False})
+ else:
+ client.create_auth_config(body)
+ existing_codes.add(code)
+ summary.append({"code": code, "created": True})
+ return summary
+
+
+def _find_forward_node(client: KarnakClient, ae_title: str) -> dict[str, Any] | None:
+ for item in client.list_forward_nodes():
+ if str(item.get("fwdAeTitle", "")) == ae_title:
+ return item
+ return None
+
+
+def _ensure_forward_node(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]:
+ node_cfg = _require_object(config, "forwardNode")
+ ae_title = str(node_cfg.get("aeTitle") or node_cfg.get("fwdAeTitle") or "").strip()
+ if not ae_title:
+ raise KarnakConfigError("config.forwardNode.aeTitle is required")
+ description = str(node_cfg.get("description") or node_cfg.get("fwdDescription") or "")
+
+ existing = _find_forward_node(client, ae_title)
+ if existing:
+ node = client.update_forward_node(int(existing["id"]), ae_title, description)
+ created = False
+ else:
+ node = client.create_forward_node(ae_title, description)
+ created = True
+ return {
+ "id": int(node["id"]),
+ "ae_title": ae_title,
+ "created": created,
+ }
+
+
+def _source_key(source: dict[str, Any]) -> tuple[str, str, bool]:
+ return (
+ str(source.get("aeTitle", "")),
+ str(source.get("hostname", "")),
+ bool(source.get("checkHostname", False)),
+ )
+
+
+def _ensure_sources(
+ client: KarnakClient,
+ forward_node_id: int,
+ config: dict[str, Any],
+) -> list[dict[str, Any]]:
+ requested = _require_list(config, "sourceNodes")
+ existing_keys = {
+ _source_key(item)
+ for item in client.list_source_nodes(forward_node_id)
+ if isinstance(item, dict)
+ }
+ summary: list[dict[str, Any]] = []
+ for body in requested:
+ if not isinstance(body, dict):
+ raise KarnakConfigError("sourceNodes entries must be objects")
+ if not str(body.get("aeTitle", "")).strip():
+ raise KarnakConfigError("sourceNodes[].aeTitle is required")
+ key = _source_key(body)
+ if key in existing_keys:
+ summary.append({"aeTitle": body.get("aeTitle"), "created": False})
+ continue
+ created = client.add_source_node(forward_node_id, body)
+ existing_keys.add(key)
+ summary.append({"id": created.get("id"), "aeTitle": body.get("aeTitle"), "created": True})
+ return summary
+
+
+def _destination_key(destination: dict[str, Any]) -> tuple[str, str]:
+ dtype = str(destination.get("destinationType") or destination.get("type") or "").lower()
+ if dtype == "stow" or destination.get("url"):
+ return ("stow", str(destination.get("url", "")))
+ return ("dicom", str(destination.get("aeTitle", "")))
+
+
+def _ensure_destinations(
+ client: KarnakClient,
+ forward_node_id: int,
+ project_id: int,
+ config: dict[str, Any],
+) -> list[dict[str, Any]]:
+ requested = _require_list(config, "destinations")
+ existing_by_key = {
+ _destination_key(item): item
+ for item in client.list_destinations(forward_node_id)
+ if isinstance(item, dict)
+ }
+ summary: list[dict[str, Any]] = []
+ for raw_body in requested:
+ if not isinstance(raw_body, dict):
+ raise KarnakConfigError("destinations entries must be objects")
+ body = dict(raw_body)
+ if body.get("desidentification") and not body.get("deIdentificationProject"):
+ body["deIdentificationProject"] = {"id": project_id}
+ if body.get("activateTagMorphing") and not body.get("tagMorphingProject"):
+ body["tagMorphingProject"] = {"id": project_id}
+
+ key = _destination_key(body)
+ if not key[1]:
+ raise KarnakConfigError("destinations require aeTitle for DICOM or url for STOW-RS")
+ existing = existing_by_key.get(key)
+ if existing:
+ destination_id = int(existing["id"])
+ merged = dict(existing)
+ merged.update(body)
+ client.update_destination(forward_node_id, destination_id, merged)
+ summary.append({"id": destination_id, "key": list(key), "created": False})
+ else:
+ created = client.add_destination(forward_node_id, body)
+ destination_id = int(created.get("id", 0)) if isinstance(created, dict) else 0
+ summary.append({"id": destination_id, "key": list(key), "created": True})
+ return summary
+
+
+def apply_config(client: KarnakClient, config: dict[str, Any]) -> dict[str, Any]:
+ profile = _ensure_profile(client, config)
+ project = _ensure_project(client, config, int(profile["id"]))
+ auth_configs = _ensure_auth_configs(client, config)
+ forward_node = _ensure_forward_node(client, config)
+ sources = _ensure_sources(client, int(forward_node["id"]), config)
+ destinations = _ensure_destinations(client, int(forward_node["id"]), int(project["id"]), config)
+
+ external_rows = _load_external_ids(config)
+ external_import: dict[str, Any] | None = None
+ if external_rows:
+ external_import = client.import_external_ids(int(project["id"]), external_rows)
+
+ return {
+ "profile": profile,
+ "project": project,
+ "auth_configs": auth_configs,
+ "forward_node": forward_node,
+ "source_nodes": sources,
+ "destinations": destinations,
+ "external_ids_import": external_import,
+ }
diff --git a/python-client/karnak_api_client/client.py b/python-client/karnak_api_client/client.py
new file mode 100644
index 00000000..2f5b3e1c
--- /dev/null
+++ b/python-client/karnak_api_client/client.py
@@ -0,0 +1,450 @@
+"""Authenticated transport client for the Karnak REST API.
+
+The client intentionally mirrors the REST resources from API.md and keeps
+policy decisions out of the transport layer. Higher-level code decides what
+"apply" means (see ``karnak_api_client.apply``); this module only performs
+authenticated HTTP calls and returns JSON-compatible dictionaries/lists.
+
+Authentication
+--------------
+Depending on how the Karnak image is configured, the REST API is reachable
+either with HTTP Basic (default in-memory IdP) or only through Spring
+form-login (POST ``/login`` once, then carry the ``JSESSIONID`` cookie).
+``auth_mode`` selects the behaviour:
+
+- ``"basic"`` — HTTP Basic on every request, no fallback.
+- ``"form"`` — form-login before the first request, cookie afterwards.
+- ``"auto"`` (default) — try Basic; when the server answers 401 or redirects
+ to ``/login``, switch to form-login transparently and retry once.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Any
+
+import requests
+from requests.auth import HTTPBasicAuth
+
+_AUTH_MODES = ("auto", "basic", "form")
+
+
+class KarnakError(RuntimeError):
+ """Base class for all Karnak client errors (incl. network failures)."""
+
+
+class AuthenticationError(KarnakError):
+ """Raised when login fails or the session is rejected."""
+
+
+class KarnakApiError(KarnakError):
+ """Raised when Karnak returns a non-success HTTP response."""
+
+ def __init__(self, method: str, url: str, status_code: int, body: str) -> None:
+ self.method = method
+ self.url = url
+ self.status_code = status_code
+ self.body = body
+ super().__init__(f"Karnak {method} {url} failed with HTTP {status_code}: {body}")
+
+
+# Backwards-compatible alias kept for callers written against the original
+# form-login client (TMLCTP harness, setup_deid_gateway.py).
+ApiError = KarnakApiError
+
+
+@dataclass(frozen=True)
+class KarnakClientConfig:
+ base_url: str
+ username: str
+ password: str
+ timeout_sec: float = 30.0
+ auth_mode: str = "auto"
+
+ def __post_init__(self) -> None:
+ if self.auth_mode not in _AUTH_MODES:
+ raise ValueError(f"auth_mode must be one of {_AUTH_MODES}, got {self.auth_mode!r}")
+
+ @classmethod
+ def from_env(cls) -> "KarnakClientConfig":
+ return cls(
+ base_url=os.getenv("KARNAK_API_BASE", "http://localhost:8081").rstrip("/"),
+ username=os.getenv("KARNAK_USERNAME", os.getenv("KARNAK_LOGIN_ADMIN", "admin")),
+ password=os.getenv("KARNAK_PASSWORD", "karnak"),
+ timeout_sec=float(os.getenv("KARNAK_API_TIMEOUT_SEC", "30")),
+ auth_mode=os.getenv("KARNAK_AUTH_MODE", "auto").strip().lower(),
+ )
+
+
+def _redirects_to_login(resp: requests.Response) -> bool:
+ if resp.status_code in (302, 303):
+ return "login" in resp.headers.get("Location", "")
+ return False
+
+
+class KarnakClient:
+ """Thin wrapper around the Karnak REST API.
+
+ Construction styles (equivalent)::
+
+ KarnakClient() # from environment
+ KarnakClient(KarnakClientConfig(...)) # explicit config
+ KarnakClient(base_url=..., username=..., password=...) # kwargs
+ """
+
+ def __init__(
+ self,
+ config: KarnakClientConfig | None = None,
+ *,
+ base_url: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ timeout_sec: float | None = None,
+ auth_mode: str | None = None,
+ session: requests.Session | None = None,
+ ) -> None:
+ cfg = config or KarnakClientConfig.from_env()
+ overrides = {
+ key: value
+ for key, value in {
+ "base_url": base_url.rstrip("/") if base_url else None,
+ "username": username,
+ "password": password,
+ "timeout_sec": timeout_sec,
+ "auth_mode": auth_mode,
+ }.items()
+ if value is not None
+ }
+ if overrides:
+ cfg = replace(cfg, **overrides)
+ self.config = cfg
+ self.session = session if session is not None else requests.Session()
+ self.session.headers.update({"Accept": "application/json"})
+ self._basic = HTTPBasicAuth(cfg.username, cfg.password)
+ self._use_form = cfg.auth_mode == "form"
+ self._form_logged_in = False
+
+ # ── authentication ──────────────────────────────────────────────────
+
+ @property
+ def base(self) -> str:
+ """Base URL (kept as an attribute-style accessor for older callers)."""
+ return self.config.base_url
+
+ def login(self) -> None:
+ """POST form credentials to /login and establish a session cookie."""
+ try:
+ resp = self.session.post(
+ f"{self.config.base_url}/login",
+ data={"username": self.config.username, "password": self.config.password},
+ allow_redirects=False,
+ timeout=self.config.timeout_sec,
+ )
+ except requests.RequestException as exc:
+ raise KarnakError(f"Cannot reach {self.config.base_url}: {exc}") from exc
+ if resp.status_code not in (302, 303):
+ raise AuthenticationError(f"Unexpected login response: HTTP {resp.status_code}")
+ location = resp.headers.get("Location", "")
+ if "error" in location or location.rstrip("/").endswith("/login"):
+ raise AuthenticationError(
+ f"Login failed — invalid credentials for {self.config.username!r}"
+ )
+ self._form_logged_in = True
+
+ def invalidate_session(self) -> None:
+ """Drop the session cookie (e.g. after a Karnak container restart)."""
+ self.session.cookies.clear()
+ self._form_logged_in = False
+
+ def check_connectivity(self, timeout: float = 5.0) -> None:
+ """Verify Karnak is reachable and credentials are accepted."""
+ self._request("GET", "/api/forward-nodes", expected={200, 204}, timeout_sec=timeout)
+
+ # ── transport ───────────────────────────────────────────────────────
+
+ def _url(self, path: str) -> str:
+ if not path.startswith("/"):
+ path = "/" + path
+ return f"{self.config.base_url}{path}"
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ json_body: Any | None = None,
+ files: dict[str, Any] | None = None,
+ params: dict[str, Any] | None = None,
+ expected: set[int] | None = None,
+ timeout_sec: float | None = None,
+ _form_retry: bool = False,
+ ) -> Any:
+ expected = expected or {200}
+ url = self._url(path)
+ if self._use_form and not self._form_logged_in:
+ self.login()
+ try:
+ resp = self.session.request(
+ method,
+ url,
+ json=json_body,
+ files=files,
+ params=params,
+ auth=None if self._use_form else self._basic,
+ allow_redirects=False,
+ timeout=timeout_sec or self.config.timeout_sec,
+ )
+ except requests.RequestException as exc:
+ raise KarnakError(f"Karnak {method} {url} failed: {exc}") from exc
+
+ unauthenticated = resp.status_code == 401 or _redirects_to_login(resp)
+ if unauthenticated and not self._use_form and not _form_retry:
+ if self.config.auth_mode == "basic":
+ raise KarnakApiError(method, url, resp.status_code, resp.text)
+ # auto mode: this Karnak build does not accept Basic on the API —
+ # switch to form-login for the rest of the client's lifetime.
+ self._use_form = True
+ self.login()
+ return self._request(
+ method,
+ path,
+ json_body=json_body,
+ files=files,
+ params=params,
+ expected=expected,
+ timeout_sec=timeout_sec,
+ _form_retry=True,
+ )
+ if unauthenticated and self._use_form and not _form_retry:
+ # Session cookie expired (e.g. Karnak restarted): log in again once.
+ self.invalidate_session()
+ self.login()
+ return self._request(
+ method,
+ path,
+ json_body=json_body,
+ files=files,
+ params=params,
+ expected=expected,
+ timeout_sec=timeout_sec,
+ _form_retry=True,
+ )
+
+ if resp.status_code not in expected:
+ raise KarnakApiError(method, url, resp.status_code, resp.text)
+ if resp.status_code == 204 or not resp.content:
+ return None
+ content_type = resp.headers.get("Content-Type", "")
+ if "json" not in content_type.lower():
+ return resp.text
+ return resp.json()
+
+ # ── health / echo ───────────────────────────────────────────────────
+
+ def echo_destinations(self, src_aet: str) -> Any:
+ # This endpoint is intentionally unauthenticated server-side, but using
+ # session auth is harmless and keeps the client simple.
+ return self._request(
+ "GET",
+ "/api/echo/destinations",
+ params={"srcAet": src_aet},
+ expected={200, 204},
+ )
+
+ # ── profiles ────────────────────────────────────────────────────────
+
+ def list_profiles(self) -> list[dict[str, Any]]:
+ return self._request("GET", "/api/profiles", expected={200, 204}) or []
+
+ def upload_profile(self, path: Path | str) -> dict[str, Any]:
+ path = Path(path)
+ # Read up-front so the auto-mode form-login retry can resend the body.
+ payload = path.read_bytes()
+ return self._request(
+ "POST",
+ "/api/profiles",
+ files={"file": (path.name, payload, "application/x-yaml")},
+ expected={201},
+ timeout_sec=max(self.config.timeout_sec, 60.0),
+ )
+
+ def get_profile(self, profile_id: int) -> dict[str, Any]:
+ return self._request("GET", f"/api/profiles/{profile_id}", expected={200})
+
+ def update_profile(self, profile_id: int, body: dict[str, Any]) -> dict[str, Any]:
+ return self._request("PUT", f"/api/profiles/{profile_id}", json_body=body, expected={200})
+
+ def delete_profile(self, profile_id: int) -> None:
+ self._request("DELETE", f"/api/profiles/{profile_id}", expected={204})
+
+ # ── projects / secrets ──────────────────────────────────────────────
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ return self._request("GET", "/api/projects", expected={200, 204}) or []
+
+ def create_project(self, name: str, profile_id: int | None = None) -> dict[str, Any]:
+ body: dict[str, Any] = {"name": name}
+ if profile_id is not None:
+ body["profileId"] = profile_id
+ return self._request("POST", "/api/projects", json_body=body, expected={201})
+
+ def get_project(self, project_id: int) -> dict[str, Any]:
+ return self._request("GET", f"/api/projects/{project_id}", expected={200})
+
+ def update_project(
+ self,
+ project_id: int,
+ *,
+ name: str | None = None,
+ profile_id: int | None = None,
+ detach_profile: bool = False,
+ ) -> dict[str, Any]:
+ body: dict[str, Any] = {}
+ if name is not None:
+ body["name"] = name
+ if detach_profile:
+ body["profileId"] = None
+ elif profile_id is not None:
+ body["profileId"] = profile_id
+ return self._request("PUT", f"/api/projects/{project_id}", json_body=body, expected={200})
+
+ def delete_project(self, project_id: int) -> None:
+ self._request("DELETE", f"/api/projects/{project_id}", expected={204})
+
+ def add_project_secret(self, project_id: int, hex_key: str | None = None) -> dict[str, Any]:
+ body: dict[str, Any] = {}
+ if hex_key:
+ body["hexKey"] = hex_key
+ return self._request(
+ "POST",
+ f"/api/projects/{project_id}/secrets",
+ json_body=body,
+ expected={201},
+ )
+
+ # Alias kept for callers written against the original form-login client.
+ generate_secret = add_project_secret
+
+ def list_external_ids(self, project_id: int) -> list[dict[str, Any]]:
+ return self._request("GET", f"/api/projects/{project_id}/external-ids", expected={200}) or []
+
+ def import_external_ids(self, project_id: int, rows: list[dict[str, Any]]) -> dict[str, Any]:
+ return self._request(
+ "POST",
+ f"/api/projects/{project_id}/external-ids/import",
+ json_body=rows,
+ expected={200},
+ )
+
+ # ── forward nodes / sources / destinations ──────────────────────────
+
+ def list_forward_nodes(self) -> list[dict[str, Any]]:
+ return self._request("GET", "/api/forward-nodes", expected={200, 204}) or []
+
+ def get_forward_node(self, forward_node_id: int) -> dict[str, Any]:
+ return self._request("GET", f"/api/forward-nodes/{forward_node_id}", expected={200})
+
+ def create_forward_node(self, ae_title: str, description: str = "") -> dict[str, Any]:
+ return self._request(
+ "POST",
+ "/api/forward-nodes",
+ json_body={"fwdAeTitle": ae_title, "fwdDescription": description},
+ expected={201},
+ )
+
+ def update_forward_node(
+ self,
+ forward_node_id: int,
+ ae_title: str,
+ description: str = "",
+ ) -> dict[str, Any]:
+ return self._request(
+ "PUT",
+ f"/api/forward-nodes/{forward_node_id}",
+ json_body={"fwdAeTitle": ae_title, "fwdDescription": description},
+ expected={200},
+ )
+
+ def delete_forward_node(self, forward_node_id: int) -> None:
+ self._request("DELETE", f"/api/forward-nodes/{forward_node_id}", expected={204})
+
+ def list_source_nodes(self, forward_node_id: int) -> list[dict[str, Any]]:
+ return self._request(
+ "GET",
+ f"/api/forward-nodes/{forward_node_id}/source-nodes",
+ expected={200, 204},
+ ) or []
+
+ def add_source_node(self, forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]:
+ return self._request(
+ "POST",
+ f"/api/forward-nodes/{forward_node_id}/source-nodes",
+ json_body=body,
+ expected={201},
+ )
+
+ def delete_source_node(self, forward_node_id: int, source_node_id: int) -> None:
+ self._request(
+ "DELETE",
+ f"/api/forward-nodes/{forward_node_id}/source-nodes/{source_node_id}",
+ expected={204},
+ )
+
+ def list_destinations(self, forward_node_id: int) -> list[dict[str, Any]]:
+ return self._request(
+ "GET",
+ f"/api/forward-nodes/{forward_node_id}/destinations",
+ expected={200, 204},
+ ) or []
+
+ def add_destination(self, forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]:
+ return self._request(
+ "POST",
+ f"/api/forward-nodes/{forward_node_id}/destinations",
+ json_body=body,
+ expected={201},
+ )
+
+ # Alias kept for callers written against the original form-login client.
+ create_destination = add_destination
+
+ def update_destination(
+ self,
+ forward_node_id: int,
+ destination_id: int,
+ body: dict[str, Any],
+ ) -> dict[str, Any]:
+ return self._request(
+ "PUT",
+ f"/api/forward-nodes/{forward_node_id}/destinations/{destination_id}",
+ json_body=body,
+ expected={200},
+ )
+
+ def delete_destination(self, forward_node_id: int, destination_id: int) -> None:
+ self._request(
+ "DELETE",
+ f"/api/forward-nodes/{forward_node_id}/destinations/{destination_id}",
+ expected={204},
+ )
+
+ # ── auth configs ────────────────────────────────────────────────────
+
+ def list_auth_configs(self) -> list[dict[str, Any]]:
+ return self._request("GET", "/api/auth-configs", expected={200, 204}) or []
+
+ def create_auth_config(self, body: dict[str, Any]) -> dict[str, Any]:
+ return self._request("POST", "/api/auth-configs", json_body=body, expected={201})
+
+ def update_auth_config(self, code: str, body: dict[str, Any]) -> dict[str, Any]:
+ return self._request("PUT", f"/api/auth-configs/{code}", json_body=body, expected={200})
+
+ # ── monitoring ──────────────────────────────────────────────────────
+
+ def query_transfers(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
+ return self._request("GET", "/api/monitoring/transfers", params=params or {}, expected={200})
+
+ def count_transfers(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
+ return self._request("GET", "/api/monitoring/transfers/count", params=params or {}, expected={200})
diff --git a/python-client/pyproject.toml b/python-client/pyproject.toml
new file mode 100644
index 00000000..2eb2cc4e
--- /dev/null
+++ b/python-client/pyproject.toml
@@ -0,0 +1,24 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "karnak-api-client"
+version = "0.1.0"
+description = "Python client for the Karnak REST API (karnak-endpoints fork): transport client plus idempotent desired-state apply for profiles, projects, forward nodes, destinations and auth configs."
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ "requests>=2.28",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0",
+]
+
+[tool.setuptools.packages.find]
+include = ["karnak_api_client*"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/python-client/tests/test_apply.py b/python-client/tests/test_apply.py
new file mode 100644
index 00000000..28d48f05
--- /dev/null
+++ b/python-client/tests/test_apply.py
@@ -0,0 +1,105 @@
+"""Tests for idempotent Karnak apply orchestration.
+
+Ported from MiCo-BID-pipeline tests/unit/test_karnak_apply.py when the apply
+layer moved into this package.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from karnak_api_client import apply_config
+
+
+class FakeKarnakClient:
+ def __init__(self) -> None:
+ self.updated_destinations: list[dict[str, Any]] = []
+ self.uploaded_profiles: list[Path] = []
+
+ def list_profiles(self) -> list[dict[str, Any]]:
+ return [{"id": 3, "name": "Profile A", "version": "1.0"}]
+
+ def upload_profile(self, path: Path) -> dict[str, Any]:
+ self.uploaded_profiles.append(path)
+ return {"id": 99, "name": "Uploaded"}
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ return [{"id": 4, "name": "Project A"}]
+
+ def update_project(self, project_id: int, **_kwargs: Any) -> dict[str, Any]:
+ return {"id": project_id}
+
+ def get_project(self, project_id: int) -> dict[str, Any]:
+ return {
+ "id": project_id,
+ "secretEntities": [{"id": 1, "active": True}],
+ }
+
+ def create_project(self, name: str, profile_id: int | None = None) -> dict[str, Any]:
+ return {"id": 44, "name": name, "profileId": profile_id}
+
+ def add_project_secret(self, project_id: int, hex_key: str | None = None) -> dict[str, Any]:
+ return {"projectId": project_id, "hexKey": hex_key or "generated", "active": True}
+
+ def list_auth_configs(self) -> list[dict[str, Any]]:
+ return []
+
+ def list_forward_nodes(self) -> list[dict[str, Any]]:
+ return [{"id": 5, "fwdAeTitle": "KARNAK-GATEWAY"}]
+
+ def update_forward_node(self, forward_node_id: int, ae_title: str, description: str = "") -> dict[str, Any]:
+ return {"id": forward_node_id, "fwdAeTitle": ae_title, "fwdDescription": description}
+
+ def create_forward_node(self, ae_title: str, description: str = "") -> dict[str, Any]:
+ return {"id": 55, "fwdAeTitle": ae_title, "fwdDescription": description}
+
+ def list_source_nodes(self, _forward_node_id: int) -> list[dict[str, Any]]:
+ return []
+
+ def list_destinations(self, _forward_node_id: int) -> list[dict[str, Any]]:
+ return [{"id": 6, "destinationType": "dicom", "aeTitle": "LOCAL-STORAGE"}]
+
+ def update_destination(
+ self,
+ _forward_node_id: int,
+ destination_id: int,
+ body: dict[str, Any],
+ ) -> dict[str, Any]:
+ self.updated_destinations.append({"id": destination_id, **body})
+ return {"id": destination_id, **body}
+
+ def add_destination(self, _forward_node_id: int, body: dict[str, Any]) -> dict[str, Any]:
+ return {"id": 66, **body}
+
+
+def test_apply_config_reuses_existing_resources_and_links_destination(tmp_path: Path) -> None:
+ profile = tmp_path / "profile.yml"
+ profile.write_text("name: Profile A\nversion: '1.0'\n", encoding="utf-8")
+ client = FakeKarnakClient()
+
+ summary = apply_config(
+ client, # type: ignore[arg-type]
+ {
+ "profile": {"path": str(profile), "name": "Profile A", "version": "1.0"},
+ "project": {"name": "Project A", "ensureSecret": True},
+ "forwardNode": {"aeTitle": "KARNAK-GATEWAY"},
+ "sourceNodes": [],
+ "destinations": [
+ {
+ "destinationType": "dicom",
+ "aeTitle": "LOCAL-STORAGE",
+ "hostname": "dicom_receiver",
+ "port": 11104,
+ "activate": True,
+ "desidentification": True,
+ }
+ ],
+ },
+ )
+
+ assert summary["profile"]["created"] is False
+ assert summary["project"]["created"] is False
+ assert summary["project"]["secret_created"] is False
+ assert client.uploaded_profiles == []
+ assert client.updated_destinations[0]["deIdentificationProject"] == {"id": 4}
diff --git a/python-client/tests/test_client.py b/python-client/tests/test_client.py
new file mode 100644
index 00000000..d9a4c353
--- /dev/null
+++ b/python-client/tests/test_client.py
@@ -0,0 +1,252 @@
+"""Unit tests for the unified Karnak client (auth modes + transport)."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+import requests
+
+from karnak_api_client import (
+ AuthenticationError,
+ KarnakApiError,
+ KarnakClient,
+ KarnakClientConfig,
+ KarnakError,
+)
+
+BASE = "http://karnak.test:8081"
+
+
+def make_config(auth_mode: str = "auto") -> KarnakClientConfig:
+ return KarnakClientConfig(
+ base_url=BASE, username="admin", password="karnak", auth_mode=auth_mode
+ )
+
+
+class FakeResponse:
+ def __init__(
+ self,
+ status_code: int,
+ body: Any = None,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.status_code = status_code
+ self.headers = headers or {}
+ if body is None:
+ self.content = b""
+ self.text = ""
+ elif isinstance(body, str):
+ self.content = body.encode()
+ self.text = body
+ self.headers.setdefault("Content-Type", "text/html")
+ else:
+ self.text = json.dumps(body)
+ self.content = self.text.encode()
+ self.headers.setdefault("Content-Type", "application/json")
+ self._body = body
+
+ def json(self) -> Any:
+ return self._body
+
+
+class FakeCookies:
+ def __init__(self) -> None:
+ self.cleared = 0
+
+ def clear(self) -> None:
+ self.cleared += 1
+
+
+class FakeSession:
+ """Queue-driven stand-in for requests.Session."""
+
+ def __init__(self, responses: list[FakeResponse]) -> None:
+ self.responses = list(responses)
+ self.calls: list[dict[str, Any]] = []
+ self.headers: dict[str, str] = {}
+ self.cookies = FakeCookies()
+
+ def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append({"method": method, "url": url, **kwargs})
+ if not self.responses:
+ raise AssertionError(f"Unexpected request: {method} {url}")
+ return self.responses.pop(0)
+
+ def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self.request("POST", url, **kwargs)
+
+
+def make_client(responses: list[FakeResponse], auth_mode: str = "auto") -> tuple[KarnakClient, FakeSession]:
+ session = FakeSession(responses)
+ client = KarnakClient(make_config(auth_mode), session=session) # type: ignore[arg-type]
+ return client, session
+
+
+def login_ok() -> FakeResponse:
+ return FakeResponse(302, headers={"Location": f"{BASE}/"})
+
+
+def login_redirect() -> FakeResponse:
+ return FakeResponse(302, headers={"Location": f"{BASE}/login"})
+
+
+# ── basic mode ──────────────────────────────────────────────────────────────
+
+
+def test_basic_mode_sends_basic_auth_and_parses_json() -> None:
+ client, session = make_client([FakeResponse(200, [{"id": 1}])], auth_mode="basic")
+ assert client.list_profiles() == [{"id": 1}]
+ call = session.calls[0]
+ assert call["auth"] is not None
+ assert call["allow_redirects"] is False
+
+
+def test_basic_mode_does_not_fall_back_on_401() -> None:
+ client, session = make_client([FakeResponse(401, "nope")], auth_mode="basic")
+ with pytest.raises(KarnakApiError) as exc:
+ client.list_profiles()
+ assert exc.value.status_code == 401
+ assert len(session.calls) == 1
+
+
+# ── auto mode fallback ──────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("first", [login_redirect(), FakeResponse(401, "nope")])
+def test_auto_mode_falls_back_to_form_login(first: FakeResponse) -> None:
+ client, session = make_client([first, login_ok(), FakeResponse(200, [{"id": 7}])])
+ assert client.list_profiles() == [{"id": 7}]
+ methods = [(c["method"], c["url"]) for c in session.calls]
+ assert methods == [
+ ("GET", f"{BASE}/api/profiles"),
+ ("POST", f"{BASE}/login"),
+ ("GET", f"{BASE}/api/profiles"),
+ ]
+ # After fallback the client stays in form mode: no Basic auth on retry.
+ assert session.calls[2]["auth"] is None
+ # Subsequent calls skip Basic entirely without a new login.
+ session.responses = [FakeResponse(200, [])]
+ client.list_projects()
+ assert session.calls[3]["auth"] is None
+
+
+def test_auto_mode_raises_authentication_error_on_bad_credentials() -> None:
+ client, _ = make_client(
+ [login_redirect(), FakeResponse(302, headers={"Location": f"{BASE}/login?error"})]
+ )
+ with pytest.raises(AuthenticationError):
+ client.list_profiles()
+
+
+# ── form mode ───────────────────────────────────────────────────────────────
+
+
+def test_form_mode_logs_in_before_first_request() -> None:
+ client, session = make_client([login_ok(), FakeResponse(200, [])], auth_mode="form")
+ client.list_profiles()
+ methods = [(c["method"], c["url"]) for c in session.calls]
+ assert methods[0] == ("POST", f"{BASE}/login")
+ assert session.calls[1]["auth"] is None
+
+
+def test_form_mode_relogins_when_session_expires() -> None:
+ client, session = make_client(
+ [login_ok(), login_redirect(), login_ok(), FakeResponse(200, [{"id": 2}])],
+ auth_mode="form",
+ )
+ assert client.list_profiles() == [{"id": 2}]
+ assert session.cookies.cleared == 1
+ methods = [(c["method"], c["url"]) for c in session.calls]
+ assert methods == [
+ ("POST", f"{BASE}/login"),
+ ("GET", f"{BASE}/api/profiles"),
+ ("POST", f"{BASE}/login"),
+ ("GET", f"{BASE}/api/profiles"),
+ ]
+
+
+def test_invalidate_session_clears_cookies_and_forces_relogin() -> None:
+ client, session = make_client(
+ [login_ok(), FakeResponse(200, []), login_ok(), FakeResponse(200, [])],
+ auth_mode="form",
+ )
+ client.list_profiles()
+ client.invalidate_session()
+ client.list_profiles()
+ posts = [c for c in session.calls if c["method"] == "POST"]
+ assert len(posts) == 2
+
+
+# ── transport behaviour ─────────────────────────────────────────────────────
+
+
+def test_204_responses_map_to_empty_list() -> None:
+ client, _ = make_client([FakeResponse(204)], auth_mode="basic")
+ assert client.list_forward_nodes() == []
+
+
+def test_unexpected_status_raises_api_error_with_details() -> None:
+ client, _ = make_client([FakeResponse(500, "boom")], auth_mode="basic")
+ with pytest.raises(KarnakApiError) as exc:
+ client.get_project(3)
+ assert exc.value.status_code == 500
+ assert exc.value.method == "GET"
+ assert "boom" in exc.value.body
+
+
+def test_connection_error_wrapped_as_karnak_error() -> None:
+ class ExplodingSession(FakeSession):
+ def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
+ raise requests.ConnectionError("refused")
+
+ session = ExplodingSession([])
+ client = KarnakClient(make_config("basic"), session=session) # type: ignore[arg-type]
+ with pytest.raises(KarnakError):
+ client.check_connectivity()
+
+
+def test_upload_profile_accepts_str_and_survives_form_fallback(tmp_path: Path) -> None:
+ profile = tmp_path / "p.yml"
+ profile.write_bytes(b"name: X\n")
+ client, session = make_client(
+ [login_redirect(), login_ok(), FakeResponse(201, {"id": 12, "name": "X"})]
+ )
+ created = client.upload_profile(str(profile))
+ assert created["id"] == 12
+ # Both the first attempt and the retry carried the file payload.
+ uploads = [c for c in session.calls if c["url"].endswith("/api/profiles")]
+ for call in uploads:
+ name, payload, content_type = call["files"]["file"]
+ assert name == "p.yml"
+ assert payload == b"name: X\n"
+ assert content_type == "application/x-yaml"
+
+
+# ── compatibility surface ───────────────────────────────────────────────────
+
+
+def test_aliases_point_at_canonical_methods() -> None:
+ assert KarnakClient.generate_secret is KarnakClient.add_project_secret
+ assert KarnakClient.create_destination is KarnakClient.add_destination
+
+
+def test_kwargs_constructor_and_base_property() -> None:
+ session = FakeSession([])
+ client = KarnakClient(
+ base_url=f"{BASE}/",
+ username="u",
+ password="p",
+ auth_mode="form",
+ session=session, # type: ignore[arg-type]
+ )
+ assert client.base == BASE
+ assert client.config.username == "u"
+ assert client.config.auth_mode == "form"
+
+
+def test_invalid_auth_mode_rejected() -> None:
+ with pytest.raises(ValueError):
+ KarnakClientConfig(base_url=BASE, username="u", password="p", auth_mode="oauth")
diff --git a/setup_deid_gateway.py b/setup_deid_gateway.py
new file mode 100644
index 00000000..5adac66e
--- /dev/null
+++ b/setup_deid_gateway.py
@@ -0,0 +1,360 @@
+#!/usr/bin/env python3
+"""
+setup_deid_gateway.py
+---------------------
+Automates three steps against the Karnak REST API:
+ 1. Upload a de-identification profile YAML file
+ 2. Create a project linked to that profile (+ generate an HMAC secret)
+ 3. Add a de-identified DICOM destination to a forward node using that project
+
+Uses form-login session authentication via _karnak_api.KarnakClient.
+
+Usage
+-----
+ python setup_deid_gateway.py --profile my-profile.yml \
+ --project-name "Clinical Trial 2024" \
+ --fwd-aet GATEWAY1 \
+ --dest-aet PACS_ANON \
+ --dest-host 192.168.1.50 \
+ --dest-port 11112
+
+ # Pseudonym from a DICOM tag:
+ python setup_deid_gateway.py ... --pseudonym-type EXTID_IN_TAG \
+ --pseudonym-tag 00100010 --pseudonym-delimiter "^" --pseudonym-position 0
+
+ # Use an existing forward node by ID instead of creating one:
+ python setup_deid_gateway.py --profile my-profile.yml \
+ --project-name "Clinical Trial 2024" \
+ --fwd-id 3 \
+ --dest-aet PACS_ANON \
+ --dest-host 192.168.1.50 \
+ --dest-port 11112
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+# The client lives in python-client/ (installable as karnak-api-client); the
+# path insert lets this script run from a bare checkout without pip install.
+sys.path.insert(0, str(Path(__file__).resolve().parent / "python-client"))
+
+from karnak_api_client import ApiError, AuthenticationError, KarnakClient, KarnakError # noqa: E402
+
+PSEUDONYM_TYPES = ("CACHE_EXTID", "EXTID_IN_TAG", "EXTID_API")
+
+
+def die(msg: str) -> None:
+ print(f"ERROR: {msg}", file=sys.stderr)
+ sys.exit(1)
+
+
+# ---------------------------------------------------------------------------
+# Step 1: Upload profile YAML
+# ---------------------------------------------------------------------------
+
+def upload_profile(client: KarnakClient, profile_path: str) -> int:
+ print(f"[1/3] Uploading profile: {profile_path}")
+ data = client.upload_profile(profile_path)
+ profile_id = data["id"]
+ print(f" Profile uploaded id={profile_id} name={data.get('name', '?')}")
+ return profile_id
+
+
+# ---------------------------------------------------------------------------
+# Step 2: Create project + HMAC secret
+# ---------------------------------------------------------------------------
+
+def create_project(client: KarnakClient, name: str, profile_id: int) -> int:
+ print(f"[2/3] Creating project: {name!r} (profileId={profile_id})")
+ data = client.create_project(name, profile_id)
+ project_id = data["id"]
+ print(f" Project created id={project_id}")
+
+ secret = client.generate_secret(project_id)
+ print(f" HMAC secret {secret.get('displayKey', '(generated)')}")
+
+ return project_id
+
+
+# ---------------------------------------------------------------------------
+# Destination payload (de-id + pseudonym type)
+# ---------------------------------------------------------------------------
+
+def build_destination_payload(
+ *,
+ project_id: int,
+ dest_description: str,
+ dest_aet: str,
+ dest_host: str,
+ dest_port: int,
+ pseudonym_type: str,
+ issuer_by_default: str | None,
+ pseudonym_tag: str | None,
+ pseudonym_delimiter: str | None,
+ pseudonym_position: int | None,
+ save_pseudonym: bool | None,
+ pseudonym_url: str | None,
+ pseudonym_method: str | None,
+ pseudonym_response_path: str | None,
+ pseudonym_body: str | None,
+ pseudonym_auth_config: str | None,
+) -> dict:
+ payload: dict = {
+ "destinationType": "dicom",
+ "description": dest_description,
+ "aeTitle": dest_aet,
+ "hostname": dest_host,
+ "port": dest_port,
+ "activate": True,
+ "desidentification": True,
+ "deIdentificationProject": {"id": project_id},
+ "pseudonymType": pseudonym_type,
+ }
+ if issuer_by_default:
+ payload["issuerByDefault"] = issuer_by_default
+
+ if pseudonym_type == "EXTID_IN_TAG":
+ payload["tag"] = pseudonym_tag
+ if pseudonym_delimiter is not None:
+ payload["delimiter"] = pseudonym_delimiter
+ if pseudonym_position is not None:
+ payload["position"] = pseudonym_position
+ if save_pseudonym is not None:
+ payload["savePseudonym"] = save_pseudonym
+ elif pseudonym_type == "EXTID_API":
+ payload["pseudonymUrl"] = pseudonym_url
+ payload["method"] = pseudonym_method
+ payload["responsePath"] = pseudonym_response_path
+ if pseudonym_body:
+ payload["body"] = pseudonym_body
+ if pseudonym_auth_config:
+ payload["authConfig"] = pseudonym_auth_config
+
+ return payload
+
+
+def validate_pseudonym_args(args: argparse.Namespace) -> None:
+ if args.pseudonym_type == "EXTID_IN_TAG":
+ if not args.pseudonym_tag:
+ die("--pseudonym-tag is required when --pseudonym-type is EXTID_IN_TAG")
+ if args.pseudonym_position is not None and args.pseudonym_position > 0 and not args.pseudonym_delimiter:
+ die("--pseudonym-delimiter is required when --pseudonym-position > 0")
+ if args.pseudonym_delimiter and args.pseudonym_position is None:
+ die("--pseudonym-position is required when --pseudonym-delimiter is set")
+ elif args.pseudonym_type == "EXTID_API":
+ missing = [
+ name
+ for name, value in (
+ ("--pseudonym-url", args.pseudonym_url),
+ ("--pseudonym-method", args.pseudonym_method),
+ ("--pseudonym-response-path", args.pseudonym_response_path),
+ )
+ if not value
+ ]
+ if missing:
+ die(f"{', '.join(missing)} required when --pseudonym-type is EXTID_API")
+ if args.pseudonym_method == "POST" and not args.pseudonym_body:
+ die("--pseudonym-body is required when --pseudonym-method is POST")
+
+
+# ---------------------------------------------------------------------------
+# Step 3: Resolve / create forward node, then add de-identified destination
+# ---------------------------------------------------------------------------
+
+def setup_gateway(
+ client: KarnakClient,
+ project_id: int,
+ fwd_id: int | None,
+ fwd_aet: str | None,
+ fwd_description: str,
+ dest_aet: str,
+ dest_host: str,
+ dest_port: int,
+ dest_description: str,
+ pseudonym_type: str,
+ issuer_by_default: str | None,
+ pseudonym_tag: str | None,
+ pseudonym_delimiter: str | None,
+ pseudonym_position: int | None,
+ save_pseudonym: bool | None,
+ pseudonym_url: str | None,
+ pseudonym_method: str | None,
+ pseudonym_response_path: str | None,
+ pseudonym_body: str | None,
+ pseudonym_auth_config: str | None,
+) -> None:
+ print("[3/3] Configuring gateway destination with de-identification")
+
+ if fwd_id is not None:
+ node = client.get_forward_node(fwd_id)
+ print(f" Using existing forward node id={fwd_id} aet={node.get('fwdAeTitle', '?')}")
+ else:
+ if not fwd_aet:
+ die("Provide --fwd-id or --fwd-aet to identify the forward node.")
+ try:
+ node = client.create_forward_node(fwd_aet, fwd_description)
+ fwd_id = node["id"]
+ print(f" Forward node created id={fwd_id} aet={fwd_aet}")
+ except ApiError as exc:
+ if exc.status_code != 409:
+ raise
+ # Already exists — find it by listing
+ all_nodes = client.list_forward_nodes()
+ matches = [n for n in all_nodes if n.get("fwdAeTitle") == fwd_aet]
+ if not matches:
+ die(f"Forward node AET {fwd_aet!r} conflict but not found in list.")
+ node = matches[0]
+ fwd_id = node["id"]
+ print(f" Forward node already exists id={fwd_id} aet={fwd_aet}")
+
+ dest_payload = build_destination_payload(
+ project_id=project_id,
+ dest_description=dest_description,
+ dest_aet=dest_aet,
+ dest_host=dest_host,
+ dest_port=dest_port,
+ pseudonym_type=pseudonym_type,
+ issuer_by_default=issuer_by_default,
+ pseudonym_tag=pseudonym_tag,
+ pseudonym_delimiter=pseudonym_delimiter,
+ pseudonym_position=pseudonym_position,
+ save_pseudonym=save_pseudonym,
+ pseudonym_url=pseudonym_url,
+ pseudonym_method=pseudonym_method,
+ pseudonym_response_path=pseudonym_response_path,
+ pseudonym_body=pseudonym_body,
+ pseudonym_auth_config=pseudonym_auth_config,
+ )
+ dest = client.create_destination(fwd_id, dest_payload)
+ print(
+ f" Destination created id={dest.get('id', '?')} "
+ f"aet={dest_aet} host={dest_host}:{dest_port}"
+ )
+ print(" De-identification: ENABLED")
+ print(f" Pseudonym type: {pseudonym_type}")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="Upload a profile, create a project, and wire it to a Karnak de-id gateway."
+ )
+ p.add_argument("--base-url", default="http://localhost:8081", help="Karnak base URL")
+ p.add_argument("--user", default="admin", help="Login username")
+ p.add_argument("--password", default="karnak", help="Login password")
+
+ p.add_argument("--profile", required=True, metavar="FILE", help="Path to profile YAML file")
+ p.add_argument("--project-name", required=True, metavar="NAME", help="Project name to create")
+
+ fwd = p.add_mutually_exclusive_group(required=True)
+ fwd.add_argument("--fwd-id", type=int, metavar="ID", help="Existing forward node ID")
+ fwd.add_argument("--fwd-aet", metavar="AET", help="Forward node AE Title (create if absent)")
+
+ p.add_argument("--fwd-description", default="De-identification gateway", metavar="TEXT")
+
+ p.add_argument("--dest-aet", required=True, metavar="AET", help="Destination AE Title")
+ p.add_argument("--dest-host", required=True, metavar="HOST", help="Destination hostname/IP")
+ p.add_argument("--dest-port", required=True, type=int, metavar="PORT", help="Destination port")
+ p.add_argument(
+ "--dest-description", default="De-identified DICOM archive", metavar="TEXT"
+ )
+
+ p.add_argument(
+ "--pseudonym-type",
+ default="CACHE_EXTID",
+ choices=PSEUDONYM_TYPES,
+ help="How to resolve the external pseudonym (default: CACHE_EXTID)",
+ )
+ p.add_argument(
+ "--issuer-by-default",
+ default=None,
+ metavar="TEXT",
+ help="Default Issuer of Patient ID for de-identification",
+ )
+
+ tag = p.add_argument_group("EXTID_IN_TAG options")
+ tag.add_argument("--pseudonym-tag", metavar="TAG", help="DICOM tag, 8 hex digits (e.g. 00100010)")
+ tag.add_argument("--pseudonym-delimiter", metavar="CHAR", help="Delimiter to split tag value")
+ tag.add_argument("--pseudonym-position", type=int, metavar="N", help="Index after split (0-based)")
+ tag.add_argument(
+ "--save-pseudonym",
+ action="store_true",
+ help="Store pseudonym read from tag in project cache",
+ )
+
+ api = p.add_argument_group("EXTID_API options")
+ api.add_argument("--pseudonym-url", metavar="URL", help="External API URL")
+ api.add_argument(
+ "--pseudonym-method",
+ choices=("GET", "POST"),
+ help="HTTP method (required for EXTID_API)",
+ )
+ api.add_argument(
+ "--pseudonym-response-path", metavar="PATH", help="JSON path to pseudonym in response"
+ )
+ api.add_argument("--pseudonym-body", metavar="JSON", help="Request body (required for POST)")
+ api.add_argument(
+ "--pseudonym-auth-config",
+ metavar="CODE",
+ help="Auth config code from /api/auth-configs",
+ )
+
+ return p.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ validate_pseudonym_args(args)
+
+ client = KarnakClient(
+ base_url=args.base_url,
+ username=args.user,
+ password=args.password,
+ )
+
+ try:
+ client.check_connectivity()
+ except AuthenticationError as exc:
+ die(str(exc))
+ except KarnakError as exc:
+ die(str(exc))
+
+ try:
+ profile_id = upload_profile(client, args.profile)
+ project_id = create_project(client, args.project_name, profile_id)
+ setup_gateway(
+ client=client,
+ project_id=project_id,
+ fwd_id=args.fwd_id,
+ fwd_aet=args.fwd_aet,
+ fwd_description=args.fwd_description,
+ dest_aet=args.dest_aet,
+ dest_host=args.dest_host,
+ dest_port=args.dest_port,
+ dest_description=args.dest_description,
+ pseudonym_type=args.pseudonym_type,
+ issuer_by_default=args.issuer_by_default,
+ pseudonym_tag=args.pseudonym_tag,
+ pseudonym_delimiter=args.pseudonym_delimiter,
+ pseudonym_position=args.pseudonym_position,
+ save_pseudonym=args.save_pseudonym if args.save_pseudonym else None,
+ pseudonym_url=args.pseudonym_url,
+ pseudonym_method=args.pseudonym_method,
+ pseudonym_response_path=args.pseudonym_response_path,
+ pseudonym_body=args.pseudonym_body,
+ pseudonym_auth_config=args.pseudonym_auth_config,
+ )
+ except (ApiError, KarnakError) as exc:
+ die(str(exc))
+
+ print("\nDone.")
+ print(f" Profile ID : {profile_id}")
+ print(f" Project ID : {project_id}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/main/java/org/karnak/backend/config/SecurityConfiguration.java b/src/main/java/org/karnak/backend/config/SecurityConfiguration.java
index 98ceeff4..0357589a 100644
--- a/src/main/java/org/karnak/backend/config/SecurityConfiguration.java
+++ b/src/main/java/org/karnak/backend/config/SecurityConfiguration.java
@@ -80,7 +80,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.permitAll()
// Allow endpoints
.requestMatchers(HttpMethod.GET, "/api/echo/destinations")
- .permitAll())
+ .permitAll()
+ // Management REST API: require authentication explicitly. Without
+ // this, requests fall through to VaadinSecurityConfigurer's own
+ // route-based access control below, which treats /api/** as an
+ // unrecognized Vaadin route and denies it (403) even for an
+ // authenticated user, instead of just requiring authentication.
+ .requestMatchers("/api/**")
+ .authenticated())
+ // The management REST API is called by non-browser clients over HTTP
+ // Basic/Bearer, which never carry the CSRF token a browser session
+ // would. Spring Security's default CSRF protection would otherwise
+ // reject every POST/PUT/DELETE call to /api/** with 403, regardless of
+ // valid credentials, so exempt it the same way a stateless REST API
+ // conventionally is exempted.
+ .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
// OpenId connect login: map the IDP realm/client roles to the Karnak roles
// so that @RolesAllowed annotations on the views work with OIDC users. The
// roles are read from the Bearer/access token (not the ID token) via a
diff --git a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java
index 51873a8a..565e83a0 100644
--- a/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java
+++ b/src/main/java/org/karnak/backend/config/SecurityInMemoryConfig.java
@@ -22,6 +22,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -61,7 +62,25 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.permitAll()
// Allow endpoints
.requestMatchers(HttpMethod.GET, "/api/echo/destinations")
- .permitAll())
+ .permitAll()
+ // Management REST API: require authentication explicitly. Without
+ // this, requests fall through to VaadinSecurityConfigurer's own
+ // route-based access control below, which treats /api/** as an
+ // unrecognized Vaadin route and denies it (403) even for an
+ // authenticated user, instead of just requiring authentication.
+ .requestMatchers("/api/**")
+ .authenticated())
+ // The management REST API is called by non-browser clients over HTTP
+ // Basic, which never carry the CSRF token a browser session would.
+ // Spring Security's default CSRF protection would otherwise reject
+ // every POST/PUT/DELETE call to /api/** with 403, regardless of valid
+ // credentials, so exempt it the same way a stateless REST API
+ // conventionally is exempted.
+ .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
+ // Enable HTTP Basic authentication so the management REST API (/api/**,
+ // beyond the explicitly permitted echo endpoint) can be called by
+ // non-browser clients, alongside the Vaadin/form-login session below.
+ .httpBasic(Customizer.withDefaults())
// Vaadin/Spring Security integration: permits the framework internal
// requests and the @AnonymousAllowed views, scopes CSRF, configures the
// request cache, the form login on the login view and requires
diff --git a/src/main/java/org/karnak/backend/constant/EndPoint.java b/src/main/java/org/karnak/backend/constant/EndPoint.java
index 476b7614..33056144 100644
--- a/src/main/java/org/karnak/backend/constant/EndPoint.java
+++ b/src/main/java/org/karnak/backend/constant/EndPoint.java
@@ -16,6 +16,16 @@ public class EndPoint {
public static final String DESTINATIONS_PATH = "/destinations";
+ public static final String FORWARD_NODES_PATH = "/api/forward-nodes";
+
+ public static final String PROFILES_PATH = "/api/profiles";
+
+ public static final String PROJECTS_PATH = "/api/projects";
+
+ public static final String AUTH_CONFIGS_PATH = "/api/auth-configs";
+
+ public static final String MONITORING_PATH = "/api/monitoring";
+
// Params
public static final String SRC_AET_PARAM = "srcAet";
diff --git a/src/main/java/org/karnak/backend/controller/AuthConfigController.java b/src/main/java/org/karnak/backend/controller/AuthConfigController.java
new file mode 100644
index 00000000..9d237611
--- /dev/null
+++ b/src/main/java/org/karnak/backend/controller/AuthConfigController.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2024-2026 Karnak Team and other contributors.
+ *
+ * This program and the accompanying materials are made available under the terms of the Eclipse
+ * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache
+ * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+ */
+package org.karnak.backend.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.karnak.backend.constant.EndPoint;
+import org.karnak.backend.data.entity.AuthConfigEntity;
+import org.karnak.backend.data.repo.AuthConfigRepo;
+import org.karnak.backend.enums.AuthConfigType;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Rest controller managing OAuth2 authentication configurations for STOW-RS destinations
+ */
+@RestController
+@RequestMapping(EndPoint.AUTH_CONFIGS_PATH)
+@Tag(name = "AuthConfig", description = "API Endpoints for Authentication Configurations (OAuth2)")
+public class AuthConfigController {
+
+ private final AuthConfigRepo authConfigRepo;
+
+ @Autowired
+ public AuthConfigController(final AuthConfigRepo authConfigRepo) {
+ this.authConfigRepo = authConfigRepo;
+ }
+
+ @Operation(summary = "List all authentication configurations")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth configs found"),
+ @ApiResponse(responseCode = "204", description = "No auth configs", content = @Content) })
+ @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> getAllAuthConfigs() {
+ List configs = authConfigRepo.findAll();
+ return configs.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(configs);
+ }
+
+ @Operation(summary = "Create an authentication configuration",
+ description = "Body: {\"code\": \"my-auth\", \"clientId\": \"...\", \"clientSecret\": \"...\","
+ + " \"accessTokenUrl\": \"https://...\", \"scope\": \"openid\"}")
+ @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Auth config created"),
+ @ApiResponse(responseCode = "400", description = "Missing required fields", content = @Content),
+ @ApiResponse(responseCode = "409", description = "Code already exists", content = @Content) })
+ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity createAuthConfig(@RequestBody AuthConfigEntity authConfigEntity) {
+ if (authConfigEntity.getCode() == null || authConfigEntity.getCode().isBlank()) {
+ return ResponseEntity.badRequest().build();
+ }
+ if (authConfigRepo.findByCode(authConfigEntity.getCode()) != null) {
+ return ResponseEntity.status(409).build();
+ }
+ authConfigEntity.setId(null);
+ if (authConfigEntity.getAuthConfigType() == null) {
+ authConfigEntity.setAuthConfigType(AuthConfigType.OAUTH2);
+ }
+ AuthConfigEntity saved = authConfigRepo.save(authConfigEntity);
+ return ResponseEntity.status(201).body(saved);
+ }
+
+ @Operation(summary = "Get an authentication configuration by its code identifier")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth config found"),
+ @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) })
+ @GetMapping(value = "/{code}", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity getAuthConfig(@PathVariable String code) {
+ AuthConfigEntity config = authConfigRepo.findByCode(code);
+ return config == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(config);
+ }
+
+ @Operation(summary = "Update an authentication configuration",
+ description = "Updatable fields: clientId, clientSecret, accessTokenUrl, scope")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Auth config updated"),
+ @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) })
+ @PutMapping(value = "/{code}", consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity updateAuthConfig(@PathVariable String code,
+ @RequestBody AuthConfigEntity incoming) {
+ AuthConfigEntity existing = authConfigRepo.findByCode(code);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ if (incoming.getClientId() != null) {
+ existing.setClientId(incoming.getClientId());
+ }
+ if (incoming.getClientSecret() != null) {
+ existing.setClientSecret(incoming.getClientSecret());
+ }
+ if (incoming.getAccessTokenUrl() != null) {
+ existing.setAccessTokenUrl(incoming.getAccessTokenUrl());
+ }
+ if (incoming.getScope() != null) {
+ existing.setScope(incoming.getScope());
+ }
+ return ResponseEntity.ok(authConfigRepo.save(existing));
+ }
+
+ @Operation(summary = "Delete an authentication configuration")
+ @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Auth config deleted"),
+ @ApiResponse(responseCode = "404", description = "Auth config not found", content = @Content) })
+ @DeleteMapping(value = "/{code}")
+ public ResponseEntity deleteAuthConfig(@PathVariable String code) {
+ AuthConfigEntity existing = authConfigRepo.findByCode(code);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ authConfigRepo.delete(existing);
+ return ResponseEntity.noContent().build();
+ }
+
+}
diff --git a/src/main/java/org/karnak/backend/controller/ForwardNodeController.java b/src/main/java/org/karnak/backend/controller/ForwardNodeController.java
new file mode 100644
index 00000000..b806251b
--- /dev/null
+++ b/src/main/java/org/karnak/backend/controller/ForwardNodeController.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2024-2026 Karnak Team and other contributors.
+ *
+ * This program and the accompanying materials are made available under the terms of the Eclipse
+ * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache
+ * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+ */
+package org.karnak.backend.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import org.karnak.backend.constant.EndPoint;
+import org.karnak.backend.data.entity.DestinationEntity;
+import org.karnak.backend.data.entity.DicomSourceNodeEntity;
+import org.karnak.backend.data.entity.ForwardNodeEntity;
+import org.karnak.backend.service.DestinationService;
+import org.karnak.backend.service.ForwardNodeAPIService;
+import org.karnak.backend.service.ForwardNodeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Rest controller managing forward nodes, their source nodes, and destinations
+ */
+@RestController
+@RequestMapping(EndPoint.FORWARD_NODES_PATH)
+@Tag(name = "ForwardNode", description = "API Endpoints for Forward Nodes")
+public class ForwardNodeController {
+
+ private final ForwardNodeAPIService forwardNodeAPIService;
+
+ private final ForwardNodeService forwardNodeService;
+
+ private final DestinationService destinationService;
+
+ @Autowired
+ public ForwardNodeController(final ForwardNodeAPIService forwardNodeAPIService,
+ final ForwardNodeService forwardNodeService, final DestinationService destinationService) {
+ this.forwardNodeAPIService = forwardNodeAPIService;
+ this.forwardNodeService = forwardNodeService;
+ this.destinationService = destinationService;
+ }
+
+ // ======== Forward Nodes ========
+
+ @Operation(summary = "List all forward nodes")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward nodes found"),
+ @ApiResponse(responseCode = "204", description = "No forward nodes configured", content = @Content) })
+ @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> getAllForwardNodes() {
+ List nodes = forwardNodeService.getAllForwardNodes();
+ return nodes.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(nodes);
+ }
+
+ @Operation(summary = "Create a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Forward node created"),
+ @ApiResponse(responseCode = "400", description = "Missing or invalid fwdAeTitle", content = @Content),
+ @ApiResponse(responseCode = "409", description = "AE Title already exists", content = @Content) })
+ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity createForwardNode(@RequestBody ForwardNodeEntity forwardNodeEntity) {
+ String aeTitle = forwardNodeEntity.getFwdAeTitle();
+ if (aeTitle == null || aeTitle.isBlank()) {
+ return ResponseEntity.badRequest().build();
+ }
+ boolean aeExists = forwardNodeService.getAllForwardNodes()
+ .stream()
+ .anyMatch(f -> Objects.equals(f.getFwdAeTitle(), aeTitle));
+ if (aeExists) {
+ return ResponseEntity.status(409).build();
+ }
+ forwardNodeEntity.setId(null);
+ forwardNodeAPIService.addForwardNode(forwardNodeEntity);
+ return ResponseEntity.status(201).body(forwardNodeEntity);
+ }
+
+ @Operation(summary = "Get a forward node by ID")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward node found"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity getForwardNode(@PathVariable Long id) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ return node == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(node);
+ }
+
+ @Operation(summary = "Update a forward node",
+ description = "Only fwdAeTitle and fwdDescription are updatable. "
+ + "Source nodes and destinations are preserved (manage them via their own endpoints).")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Forward node updated"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity updateForwardNode(@PathVariable Long id,
+ @RequestBody ForwardNodeEntity forwardNodeEntity) {
+ ForwardNodeEntity existing = forwardNodeAPIService.getForwardNodeById(id);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ // Merge only fwdAeTitle/fwdDescription onto existing entity so that the
+ // associated source nodes and destinations (cascade=ALL + orphanRemoval)
+ // are not silently wiped out by an incoming partial payload.
+ if (forwardNodeEntity.getFwdAeTitle() != null && !forwardNodeEntity.getFwdAeTitle().isBlank()) {
+ existing.setFwdAeTitle(forwardNodeEntity.getFwdAeTitle());
+ }
+ if (forwardNodeEntity.getFwdDescription() != null) {
+ existing.setFwdDescription(forwardNodeEntity.getFwdDescription());
+ }
+ forwardNodeAPIService.updateForwardNode(existing);
+ return ResponseEntity.ok(existing);
+ }
+
+ @Operation(summary = "Delete a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Forward node deleted"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @DeleteMapping(value = "/{id}")
+ public ResponseEntity deleteForwardNode(@PathVariable Long id) {
+ ForwardNodeEntity existing = forwardNodeAPIService.getForwardNodeById(id);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ forwardNodeAPIService.deleteForwardNode(existing);
+ return ResponseEntity.noContent().build();
+ }
+
+ // ======== Source Nodes ========
+
+ @Operation(summary = "List source nodes of a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Source nodes found"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @GetMapping(value = "/{id}/source-nodes", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> getSourceNodes(@PathVariable Long id) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok(forwardNodeService.getAllSourceNodes(node));
+ }
+
+ @Operation(summary = "Add a source node to a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Source node added"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @PostMapping(value = "/{id}/source-nodes", consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity addSourceNode(@PathVariable Long id,
+ @RequestBody DicomSourceNodeEntity sourceNode) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ sourceNode.setId(null);
+ forwardNodeService.updateSourceNode(node, sourceNode);
+ return ResponseEntity.status(201).body(sourceNode);
+ }
+
+ @Operation(summary = "Remove a source node from a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Source node removed"),
+ @ApiResponse(responseCode = "404", description = "Forward node or source node not found",
+ content = @Content) })
+ @DeleteMapping(value = "/{id}/source-nodes/{sourceNodeId}")
+ public ResponseEntity deleteSourceNode(@PathVariable Long id, @PathVariable Long sourceNodeId) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ DicomSourceNodeEntity sourceNode = forwardNodeService.getSourceNodeById(node, sourceNodeId);
+ if (sourceNode == null) {
+ return ResponseEntity.notFound().build();
+ }
+ forwardNodeService.deleteSourceNode(node, sourceNode);
+ return ResponseEntity.noContent().build();
+ }
+
+ // ======== Destinations ========
+
+ @Operation(summary = "List destinations of a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Destinations found"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @GetMapping(value = "/{id}/destinations", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> getDestinations(@PathVariable Long id) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok(destinationService.retrieveDestinations(node));
+ }
+
+ @Operation(summary = "Add a destination to a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Destination added"),
+ @ApiResponse(responseCode = "404", description = "Forward node not found", content = @Content) })
+ @PostMapping(value = "/{id}/destinations", consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity addDestination(@PathVariable Long id,
+ @RequestBody DestinationEntity destinationEntity) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ destinationEntity.setId(null);
+ DestinationEntity saved = destinationService.save(node, destinationEntity);
+ return ResponseEntity.status(201).body(saved);
+ }
+
+ @Operation(summary = "Update a destination of a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Destination updated"),
+ @ApiResponse(responseCode = "404", description = "Forward node or destination not found",
+ content = @Content) })
+ @PutMapping(value = "/{id}/destinations/{destinationId}", consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity updateDestination(@PathVariable Long id, @PathVariable Long destinationId,
+ @RequestBody DestinationEntity destinationEntity) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ DestinationEntity existing = forwardNodeService.getDestinationById(node, destinationId);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ destinationEntity.setId(destinationId);
+ DestinationEntity saved = destinationService.save(node, destinationEntity);
+ return ResponseEntity.ok(saved);
+ }
+
+ @Operation(summary = "Delete a destination from a forward node")
+ @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "Destination deleted"),
+ @ApiResponse(responseCode = "404", description = "Forward node or destination not found",
+ content = @Content) })
+ @DeleteMapping(value = "/{id}/destinations/{destinationId}")
+ public ResponseEntity deleteDestination(@PathVariable Long id, @PathVariable Long destinationId) {
+ ForwardNodeEntity node = forwardNodeAPIService.getForwardNodeById(id);
+ if (node == null) {
+ return ResponseEntity.notFound().build();
+ }
+ DestinationEntity existing = forwardNodeService.getDestinationById(node, destinationId);
+ if (existing == null) {
+ return ResponseEntity.notFound().build();
+ }
+ destinationService.delete(existing);
+ return ResponseEntity.noContent().build();
+ }
+
+}
diff --git a/src/main/java/org/karnak/backend/controller/MonitoringController.java b/src/main/java/org/karnak/backend/controller/MonitoringController.java
new file mode 100644
index 00000000..8ab0f105
--- /dev/null
+++ b/src/main/java/org/karnak/backend/controller/MonitoringController.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2024-2026 Karnak Team and other contributors.
+ *
+ * This program and the accompanying materials are made available under the terms of the Eclipse
+ * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache
+ * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+ */
+package org.karnak.backend.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.time.LocalDateTime;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.karnak.backend.constant.EndPoint;
+import org.karnak.backend.data.entity.TransferSeriesStatusEntity;
+import org.karnak.backend.enums.TransferStatusType;
+import org.karnak.backend.service.TransferMonitoringService;
+import org.karnak.frontend.monitoring.component.ExportSettings;
+import org.karnak.frontend.monitoring.component.TransferStatusFilter;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Rest controller for querying and exporting DICOM transfer monitoring data
+ */
+@RestController
+@RequestMapping(EndPoint.MONITORING_PATH)
+@Tag(name = "Monitoring", description = "API Endpoints for Transfer Monitoring")
+@Slf4j
+public class MonitoringController {
+
+ private static final int MAX_PAGE_SIZE = 1000;
+
+ private final TransferMonitoringService transferMonitoringService;
+
+ @Autowired
+ public MonitoringController(final TransferMonitoringService transferMonitoringService) {
+ this.transferMonitoringService = transferMonitoringService;
+ }
+
+ @Operation(summary = "Query transfer status records",
+ description = "Returns a paginated list of aggregated per-series transfer records (one row per "
+ + "forward node/destination/series). All filter parameters are optional. "
+ + "status values: ALL, SENT, NOT_SENT, EXCLUDED, ERROR")
+ @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Transfer records returned") })
+ @GetMapping(value = "/transfers", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity