feat(backend): Taiga-Proxy — vier schmale Endpunkte, Ziel-URL aus der Server-Konfiguration (D91, #trk.create.proxy)

API First: /taiga/auth, /taiga/projects, /taiga/userstories, /taiga/tasks
in der OpenAPI-Spec; TaigaClient/TaigaProperties in
de.werkbaum.integration.taiga. Die API-URL kommt aus
WERKBAUM_TAIGA_API_URL (nie Request-Parameter — SSRF), das Token je
Aufruf im Header X-Taiga-Token (Authorization muessen OpenAPI-Werkzeuge
als Header-Parameter ignorieren) und geht als Bearer hinaus; der Server
speichert nichts und loggt keine Request-Bodies. Taiga-4xx werden samt
_error_message durchgereicht, 5xx/Netz sind 502, unkonfiguriert 503 —
und GET /info meldet das Feature (taiga). Tests gegen aufgezeichnete
Antwortformen auf einem JDK-HttpServer-Stub (statt WireMock: keine neue
Test-Abhaengigkeit, dieselbe Zusicherung); Gegenprobe: ohne den
type-Durchreich faellt genau der benannte Test. check gruen, 93 %
Coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 16:33:35 +02:00
co-authored by Claude Fable 5
parent 5a18505571
commit fd0e730656
15 changed files with 1015 additions and 5 deletions
+16 -2
View File
@@ -17,6 +17,18 @@ geplante Owner-Passwort binden (`#col.live.owner` im Plan) — Endpunkte so
schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur
zu brechen. zu brechen.
**Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*`
(auth, projects, userstories, tasks) in `de.werkbaum.integration.taiga`
(`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der
Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw.
`WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle
naiver Proxies. Das Token kommt je Aufruf im Header `X-Taiga-Token`
(eigener Name: `Authorization` müssen OpenAPI-Werkzeuge als Header-Parameter
ignorieren, und er kollidierte mit dem Master-Passwort) und geht als
`Authorization: Bearer …` hinaus; der Server speichert nichts und **loggt
keine Request-Bodies** — der Auth-Endpunkt sieht das Passwort nur im
Durchflug. Unkonfiguriert: 503, und `GET /info` meldet `taiga: false`.
## Konventionen ## Konventionen
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`, - Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`,
@@ -28,8 +40,10 @@ zu brechen.
- Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`, - Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`,
`shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die `shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die
laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in
Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten,
(WireMock), nie gegen Live-Instanzen. nie gegen Live-Instanzen — umgesetzt mit dem JDK-eigenen `HttpServer`
als Stub (`TaigaClientTest`/`TaigaApiTest`) statt WireMock: keine neue
Test-Abhängigkeit, dieselbe Zusicherung.
- Konfiguration über `application.yaml` + Umgebungsvariablen; - Konfiguration über `application.yaml` + Umgebungsvariablen;
keine Zugangsdaten im Repository. keine Zugangsdaten im Repository.
- Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md); - Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md);
@@ -33,8 +33,9 @@ class CorsConfiguration(private val properties: CorsProperties) {
val config = CorsConfiguration().apply { val config = CorsConfiguration().apply {
allowedOriginPatterns = properties.allowedOrigins allowedOriginPatterns = properties.allowedOrigins
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
// Authorization fuer das Master-Passwort, Content-Type fuer JSON. // Authorization fuer das Master-Passwort, Content-Type fuer JSON,
allowedHeaders = listOf("Authorization", "Content-Type") // X-Taiga-Token fuer den Taiga-Proxy (D91).
allowedHeaders = listOf("Authorization", "Content-Type", "X-Taiga-Token")
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet. // Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
allowCredentials = false allowCredentials = false
maxAge = 3600 maxAge = 3600
@@ -18,6 +18,7 @@ import de.werkbaum.domain.ChangeFeed
import de.werkbaum.domain.ContentPatch import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.Document import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.integration.taiga.TaigaProperties
import de.werkbaum.service.DocumentService import de.werkbaum.service.DocumentService
import de.werkbaum.service.LiveEditingService import de.werkbaum.service.LiveEditingService
import org.springframework.boot.info.BuildProperties import org.springframework.boot.info.BuildProperties
@@ -45,6 +46,7 @@ class DocumentsController(
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert. * nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
*/ */
private val buildProperties: BuildProperties? = null, private val buildProperties: BuildProperties? = null,
private val taigaProperties: TaigaProperties,
) : DocumentsApi { ) : DocumentsApi {
/** /**
@@ -60,6 +62,9 @@ class DocumentsController(
name = buildProperties?.name ?: "werkbaum-backend", name = buildProperties?.name ?: "werkbaum-backend",
version = buildProperties?.version ?: "unbekannt", version = buildProperties?.version ?: "unbekannt",
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC), builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
// Feature-Meldung des Taiga-Proxys (D91): Der Editor zeigt die
// Ticket-Aktionen nur, wo ein konfiguriertes Backend antwortet.
taiga = taigaProperties.configured,
) )
) )
@@ -8,6 +8,9 @@ import de.werkbaum.service.DocumentDeletedException
import de.werkbaum.service.DocumentNotFoundException import de.werkbaum.service.DocumentNotFoundException
import de.werkbaum.service.InvalidPatchException import de.werkbaum.service.InvalidPatchException
import de.werkbaum.service.StalePatchSequenceException import de.werkbaum.service.StalePatchSequenceException
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
import de.werkbaum.integration.taiga.TaigaUnavailableException
import de.werkbaum.integration.taiga.TaigaUpstreamException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -75,4 +78,33 @@ class GlobalExceptionHandler {
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
ex.message ?: "Ungültige Anfrage", ex.message ?: "Ungültige Anfrage",
).apply { title = "Ungültige Anfrage" } ).apply { title = "Ungültige Anfrage" }
/* ---- Taiga-Proxy (D91) ---- */
/** Kein Ziel konfiguriert: 503 — der Editor fragt vorher `GET /info`. */
@ExceptionHandler(TaigaNotConfiguredException::class)
fun handleTaigaNotConfigured(ex: TaigaNotConfiguredException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE,
ex.message ?: "Taiga nicht konfiguriert",
).apply { title = "Taiga nicht konfiguriert" }
@ExceptionHandler(TaigaUnavailableException::class)
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_GATEWAY,
ex.message ?: "Taiga nicht erreichbar",
).apply { title = "Taiga nicht erreichbar" }
/**
* Taiga hat mit einem Fehler geantwortet: 4xx wird durchgereicht — Taiga
* meldet z. B. falsche Zugangsdaten als 400, und der Text hilft dem
* Benutzer —, ein fremder 5xx wird zu 502.
*/
@ExceptionHandler(TaigaUpstreamException::class)
fun handleTaigaUpstream(ex: TaigaUpstreamException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
if (ex.status in 400..499) HttpStatus.valueOf(ex.status) else HttpStatus.BAD_GATEWAY,
ex.message ?: "Taiga-Fehler",
).apply { title = "Taiga-Fehler" }
} }
@@ -0,0 +1,79 @@
package de.werkbaum.api
import de.werkbaum.generated.api.TaigaApi
import de.werkbaum.generated.model.TaigaAuthRequest
import de.werkbaum.generated.model.TaigaProject
import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* Der Taiga-Proxy (D91) — implementiert das generierte Interface, wie der
* DocumentsController seines: Ändert sich die Spezifikation, schlägt hier
* der Compile fehl (API First).
*
* Hier gibt es nur die Abbildung API ↔ Client; alles Inhaltliche —
* Ziel-URL aus der Server-Konfiguration, Fehlerklassen, das schmale
* Antwortformat — liegt im [TaigaClient]. Kein Logging von Request-Bodies:
* Der Auth-Endpunkt sieht das Passwort nur im Durchflug.
*/
@RestController
@RequestMapping("/api/v1")
class TaigaController(private val client: TaigaClient) : TaigaApi {
override fun taigaLogin(taigaAuthRequest: TaigaAuthRequest): ResponseEntity<TaigaSession> {
val session = client.login(taigaAuthRequest.username, taigaAuthRequest.password)
return ResponseEntity.ok(
TaigaSession(
authToken = session.authToken,
userId = session.userId,
username = session.username,
fullName = session.fullName,
)
)
}
override fun taigaProjects(xTaigaToken: String, member: Long): ResponseEntity<List<TaigaProject>> =
ResponseEntity.ok(
client.projects(xTaigaToken, member).map {
TaigaProject(id = it.id, name = it.name, slug = it.slug)
}
)
override fun taigaCreateStory(
xTaigaToken: String,
taigaStoryCreateRequest: TaigaStoryCreateRequest,
): ResponseEntity<TaigaTicket> {
val ticket = client.createStory(
token = xTaigaToken,
project = taigaStoryCreateRequest.project,
subject = taigaStoryCreateRequest.subject,
)
return created(ticket)
}
override fun taigaCreateTask(
xTaigaToken: String,
taigaTaskCreateRequest: TaigaTaskCreateRequest,
): ResponseEntity<TaigaTicket> {
val ticket = client.createTask(
token = xTaigaToken,
project = taigaTaskCreateRequest.project,
subject = taigaTaskCreateRequest.subject,
userStory = taigaTaskCreateRequest.userStory,
)
return created(ticket)
}
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
)
}
@@ -0,0 +1,139 @@
package de.werkbaum.integration.taiga
import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import java.net.http.HttpClient
import java.time.Duration
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
class TaigaNotConfiguredException :
RuntimeException("Keine Taiga-Instanz konfiguriert (werkbaum.taiga.api-url)")
/** Taiga nicht erreichbar oder mit unbrauchbarer Antwort (502). */
class TaigaUnavailableException(message: String, cause: Throwable? = null) :
RuntimeException(message, cause)
/**
* Taiga hat mit einem Fehlerstatus geantwortet. 4xx wird durchgereicht
* (Taiga meldet z. B. falsche Zugangsdaten als 400), 5xx wird zu 502 —
* ein fremder Serverfehler ist aus Client-Sicht „Upstream kaputt“.
*/
class TaigaUpstreamException(val status: Int, message: String) : RuntimeException(message)
data class TaigaSessionData(
val authToken: String,
val userId: Long,
val username: String,
val fullName: String?,
)
data class TaigaProjectData(val id: Long, val name: String, val slug: String)
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
*
* Das Token kommt je Aufruf vom Browser herein und geht als
* `Authorization: Bearer …` hinaus; der Server **speichert nichts** und
* **loggt keine Request-Bodies** (der Auth-Endpunkt sieht das Passwort nur
* im Durchflug). Die Antworten werden als Maps gelesen und auf die schmalen
* Datenklassen abgebildet — so hängt nichts an Taigas übrigen Feldern.
*/
@Service
class TaigaClient(private val properties: TaigaProperties) {
private val rest: RestClient = RestClient.builder()
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
).apply { setReadTimeout(Duration.ofSeconds(20)) }
)
.build()
fun login(username: String, password: String): TaigaSessionData {
val map = exchange {
rest.post().uri(url("/auth"))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("type" to properties.authType, "username" to username, "password" to password))
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/auth)")
return TaigaSessionData(
authToken = str(map, "auth_token"),
userId = num(map, "id"),
username = str(map, "username"),
fullName = map["full_name"] as? String,
)
}
fun projects(token: String, member: Long): List<TaigaProjectData> {
val list = exchange {
rest.get().uri(url("/projects?member=$member&order_by=user_order"))
.header("Authorization", "Bearer $token")
// Taiga paginiert sonst bei 30 — die Projektliste eines
// Nutzers soll vollständig sein.
.header("x-disable-pagination", "1")
.retrieve().body(LIST)
} ?: emptyList()
return list.map { TaigaProjectData(num(it, "id"), str(it, "name"), str(it, "slug")) }
}
fun createStory(token: String, project: Long, subject: String): TaigaTicketData =
create(token, "/userstories", mapOf("project" to project, "subject" to subject))
fun createTask(token: String, project: Long, subject: String, userStory: Long): TaigaTicketData =
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
val map = exchange {
rest.post().uri(url(path))
.header("Authorization", "Bearer $token")
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($path)")
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
}
private fun url(path: String): String {
if (!properties.configured) throw TaigaNotConfiguredException()
return properties.apiUrl.trimEnd('/') + path
}
private fun <T> exchange(call: () -> T): T =
try {
call()
} catch (e: RestClientResponseException) {
// Der Fehlertext kommt aus Taigas ANTWORT (`_error_message`) —
// nie aus der Anfrage; Zugangsdaten stehen darin nicht.
throw TaigaUpstreamException(e.statusCode.value(), errorMessage(e))
} catch (e: ResourceAccessException) {
throw TaigaUnavailableException("Taiga-Instanz nicht erreichbar: ${e.message}", e)
}
private fun errorMessage(e: RestClientResponseException): String {
val fromBody = Regex("\"_error_message\"\\s*:\\s*\"([^\"]*)\"")
.find(e.responseBodyAsString)?.groupValues?.get(1)
return fromBody ?: "Taiga antwortete mit ${e.statusCode.value()}"
}
private fun str(m: Map<String, Any?>, key: String): String =
m[key] as? String
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
private fun num(m: Map<String, Any?>, key: String): Long =
(m[key] as? Number)?.toLong()
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
companion object {
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
}
}
@@ -0,0 +1,32 @@
package de.werkbaum.integration.taiga
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Der Taiga-Proxy (D91).
*
* Die Basis-URL der Taiga-API ist **Server-Konfiguration**, nie
* Request-Parameter — ein Proxy, der sein Ziel vom Aufrufer nimmt, ist ein
* offenes Relay (die SSRF-Falle naiver Proxies). Leer heißt: Feature aus;
* alle Taiga-Endpunkte antworten dann mit 503, und `GET /info` meldet
* `taiga: false`, sodass der Editor die Aktionen gar nicht erst zeigt.
*/
@ConfigurationProperties(prefix = "werkbaum.taiga")
data class TaigaProperties(
/**
* Basis-URL der Taiga-**API**, nicht des Frontends — bei der Zielinstanz
* liegt sie auf einem eigenen Host (`https://plan-api.hostsharing.net/api/v1`,
* aus deren `conf.json` gelesen; D91-Nachtrag 1).
*/
val apiUrl: String = "",
/**
* Login-Typ für `POST /auth`: `ldap` (LDAP-Plugin, so die Zielinstanz)
* oder `normal`. Nur der Auth-Endpunkt braucht ihn; bei der angekündigten
* OIDC-Umstellung wird er durch den Redirect-Flow ersetzt.
*/
val authType: String = "ldap",
) {
val configured: Boolean get() = apiUrl.isNotBlank()
}
@@ -50,6 +50,16 @@ werkbaum:
cors: cors:
allowed-origins: "*" allowed-origins: "*"
# Taiga-Proxy (D91): Die Basis-URL der Taiga-API ist SERVER-Konfiguration,
# nie Request-Parameter (SSRF-Falle naiver Proxies). Leer = Feature aus;
# GET /info meldet es (taiga). Achtung: die API-URL, nicht das Frontend -
# bei der Zielinstanz z. B. https://plan-api.hostsharing.net/api/v1
taiga:
api-url: ${WERKBAUM_TAIGA_API_URL:}
# Login-Typ der Instanz fuer POST /taiga/auth: "ldap" (LDAP-Plugin,
# plan.hostsharing.net) oder "normal".
auth-type: ${WERKBAUM_TAIGA_AUTH_TYPE:ldap}
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus # Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt. # der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
master-password: master-password:
+291
View File
@@ -20,6 +20,14 @@ servers:
tags: tags:
- name: Documents - name: Documents
description: Verwaltung von Dokumenten description: Verwaltung von Dokumenten
- name: Taiga
description: >
Schmaler, benannter Proxy zur konfigurierten Taiga-Instanz (D91).
Kein Durchreich-Proxy: Die Taiga-Basis-URL ist Server-Konfiguration
(`werkbaum.taiga.api-url`), nie Request-Parameter - die SSRF-Falle
naiver Proxies. Das Token bleibt im Browser; der Server speichert
nichts. Ohne konfigurierte Instanz antworten alle Taiga-Endpunkte
mit 503; ob sie konfiguriert ist, meldet `GET /info` (`taiga`).
paths: paths:
/documents: /documents:
@@ -363,6 +371,159 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
/taiga/auth:
post:
tags: [Taiga]
operationId: taigaLogin
summary: Bei Taiga anmelden (Proxy)
description: >
Reicht Benutzername und Passwort einmalig an die konfigurierte
Taiga-Instanz durch (`POST <api-url>/auth`, mit dem serverseitig
konfigurierten Login-Typ, Voreinstellung `ldap` - D91-Nachtrag 1).
Der Endpunkt sieht das Passwort nur im Durchflug: Der Server
speichert nichts und loggt den Request-Body nie; das Token gehoert
dem Browser. Bei der angekuendigten OIDC-Umstellung der Instanz wird
dieser Endpunkt durch den Redirect-Flow ersetzt - die uebrigen
Taiga-Endpunkte bleiben unveraendert (sie nehmen nur das Token).
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaAuthRequest"
responses:
"200":
description: Anmeldung gelungen
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaSession"
"400":
description: >
Zugangsdaten abgelehnt - Taiga meldet falsche Anmeldedaten als
400, der Status wird durchgereicht.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/projects:
get:
tags: [Taiga]
operationId: taigaProjects
summary: Projekte des angemeldeten Nutzers auflisten (Proxy)
description: >
`GET <api-url>/projects?member=<userId>` - die Auswahlliste des
Projekt-Dialogs der Ticket-Anlage. Der `slug` ist zugleich der Wert
des Schlagworts `&taiga.<slug>` (SPEC par. 1).
parameters:
- $ref: "#/components/parameters/TaigaToken"
- name: member
in: query
required: true
description: Taiga-Benutzer-Id aus der Sitzung; filtert auf die eigenen Projekte.
schema:
type: integer
format: int64
responses:
"200":
description: Projekte des Nutzers
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TaigaProject"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories:
post:
tags: [Taiga]
operationId: taigaCreateStory
summary: User Story anlegen (Proxy)
description: >
`POST <api-url>/userstories`. Die Antwort traegt die projektweite
`ref` - Werkbaum schreibt daraus `#US-<ref>` als Token an die
Knotenzeile (SPEC par. 11, D91-Nachtrag 2).
parameters:
- $ref: "#/components/parameters/TaigaToken"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaStoryCreateRequest"
responses:
"201":
description: Story wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicket"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tasks:
post:
tags: [Taiga]
operationId: taigaCreateTask
summary: Task unter einer User Story anlegen (Proxy)
description: >
`POST <api-url>/tasks`. Tasks haengen immer an einer Story
(`userStory` ist die Id, nicht die Ref) - storyless Tasks sind im
Kanban unsichtbar und werden bewusst nicht angeboten (D91). Die
Antwort traegt die `ref`; Werkbaum schreibt daraus `#T-<ref>`.
parameters:
- $ref: "#/components/parameters/TaigaToken"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTaskCreateRequest"
responses:
"201":
description: Task wurde angelegt
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicket"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/info: /info:
get: get:
tags: [Documents] tags: [Documents]
@@ -383,7 +544,37 @@ paths:
$ref: "#/components/schemas/ServiceInfo" $ref: "#/components/schemas/ServiceInfo"
components: components:
parameters:
TaigaToken:
name: X-Taiga-Token
in: header
required: true
description: >
Das Taiga-Token aus `POST /taiga/auth`, nackt (ohne `Bearer `-Praefix).
Der Proxy setzt daraus den `Authorization: Bearer <token>`-Header der
Weiterleitung. Bewusst ein eigener Header-Name: Einen Header-Parameter
namens `Authorization` muessen OpenAPI-Werkzeuge laut Spezifikation
ignorieren, und der Name kollidierte mit dem Master-Passwort (Basic).
schema:
type: string
maxLength: 512
responses: responses:
TaigaNotConfigured:
description: >
Keine Taiga-Instanz konfiguriert (`werkbaum.taiga.api-url`) - der
Editor fragt vorher `GET /info` (`taiga`) und zeigt die Aktionen
dann gar nicht erst.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
TaigaUnavailable:
description: Taiga-Instanz nicht erreichbar oder antwortet fehlerhaft
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
NotFound: NotFound:
description: Ressource nicht gefunden description: Ressource nicht gefunden
content: content:
@@ -675,6 +866,106 @@ components:
type: string type: string
format: date-time format: date-time
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE). description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
taiga:
type: boolean
description: >
true, wenn eine Taiga-Instanz konfiguriert ist
(`werkbaum.taiga.api-url`) - der Editor zeigt die Ticket-Aktionen
im Knoten-Fenster nur dann (D91).
TaigaAuthRequest:
type: object
required: [username, password]
properties:
username:
type: string
minLength: 1
maxLength: 255
password:
type: string
minLength: 1
maxLength: 255
description: Wird nur durchgereicht - nie gespeichert, nie geloggt.
TaigaSession:
type: object
required: [authToken, userId, username]
properties:
authToken:
type: string
description: >
Bearer-Token der Taiga-Sitzung. Es gehoert dem Browser; der
Server merkt sich nichts davon.
userId:
type: integer
format: int64
description: Taiga-Benutzer-Id - der `member`-Filter der Projektliste.
username:
type: string
fullName:
type: string
TaigaProject:
type: object
required: [id, name, slug]
properties:
id:
type: integer
format: int64
name:
type: string
slug:
type: string
description: >
Zugleich der Wert des Schlagworts `&taiga.<slug>` im
Notationstext (SPEC par. 1, D91-Nachtrag 3).
TaigaStoryCreateRequest:
type: object
required: [project, subject]
properties:
project:
type: integer
format: int64
description: Taiga-Projekt-Id (aus der Projektliste).
subject:
type: string
minLength: 1
maxLength: 500
TaigaTaskCreateRequest:
type: object
required: [project, subject, userStory]
properties:
project:
type: integer
format: int64
subject:
type: string
minLength: 1
maxLength: 500
userStory:
type: integer
format: int64
description: Id (nicht Ref) der User Story, unter der die Task haengt.
TaigaTicket:
type: object
required: [id, ref, subject]
properties:
id:
type: integer
format: int64
ref:
type: integer
format: int64
description: >
Projektweite Nummer, fortlaufend ueber alle Typen. Werkbaum
schreibt daraus `#US-<ref>` bzw. `#T-<ref>` an die Knotenzeile -
die Praefixe traegt Werkbaum selbst, Taiga zeigt nur `#<ref>`
(D91-Nachtrag 2).
subject:
type: string
ProblemDetail: ProblemDetail:
type: object type: object
@@ -0,0 +1,173 @@
package de.werkbaum.api
import com.sun.net.httpserver.HttpServer
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient
import java.net.InetSocketAddress
/**
* Der Taiga-Proxy Ende-zu-Ende: eigene API -> Client -> Stub-Instanz.
* Deckt die Verdrahtung ab, die der Unit-Test des Clients nicht sieht —
* generierte Signaturen, Header-Namen, Fehler-Mapping, das Feature-Flag in
* `GET /info`.
*/
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@TestPropertySource(
properties = [
// Eigene Datenbank: zweiter Spring-Kontext (siehe MasterPasswordDefaultTest).
"spring.datasource.url=jdbc:h2:mem:editor-taiga;" +
"DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
]
)
class TaigaApiTest {
@Autowired
private lateinit var client: RestTestClient
@Test
fun `info meldet das konfigurierte Taiga-Feature`() {
val result = client.get().uri("/api/v1/info").exchange().returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"taiga\":true"
}
@Test
fun `die Anmeldung liefert die schmale Sitzung in camelCase`() {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"geheim"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"authToken\":\"tok-abc123\""
result.responseBody!! shouldContain "\"userId\":42"
}
@Test
fun `abgelehnte Zugangsdaten kommen als 400 mit Taigas Fehlertext an`() {
stubStatus = 400
stubBody = TaigaClientTestData.AUTH_FAIL
try {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"falsch"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 400
result.responseBody!! shouldContain "does not matches"
} finally {
stubStatus = 200
stubBody = null
}
}
@Test
fun `die Projektliste nimmt das Token aus X-Taiga-Token`() {
val result = client.get()
.uri("/api/v1/taiga/projects?member=42")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"slug\":\"mi-intern\""
}
@Test
fun `eine angelegte Story antwortet mit 201 und ihrer Ref`() {
val result = client.post()
.uri("/api/v1/taiga/userstories")
.header("X-Taiga-Token", "tok-abc123")
.header("Content-Type", "application/json")
.body("""{"project":7,"subject":"Backend bauen"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 201
result.responseBody!! shouldContain "\"ref\":123"
}
@Test
fun `eine angelegte Task antwortet mit 201 und ihrer Ref`() {
val result = client.post()
.uri("/api/v1/taiga/tasks")
.header("X-Taiga-Token", "tok-abc123")
.header("Content-Type", "application/json")
.body("""{"project":7,"subject":"API-Teil","userStory":1234}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 201
result.responseBody!! shouldContain "\"ref\":124"
}
companion object {
private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200
@Volatile private var stubBody: String? = null
private fun startStub() {
if (::stub.isInitialized) return
stub = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
stub.createContext("/") { ex ->
val canned = stubBody ?: when (ex.requestURI.path) {
"/api/v1/auth" -> TaigaClientTestData.AUTH_OK
"/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK
"/api/v1/userstories" -> TaigaClientTestData.STORY_OK
"/api/v1/tasks" -> TaigaClientTestData.TASK_OK
else -> "{}"
}
val status = if (stubBody != null) stubStatus
else if (ex.requestMethod == "POST" && ex.requestURI.path != "/api/v1/auth") 201
else 200
ex.requestBody.readBytes()
val bytes = canned.encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(status, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) }
}
stub.start()
}
@JvmStatic
@AfterAll
fun stopStub() {
if (::stub.isInitialized) stub.stop(0)
}
/* Der Stub muss VOR dem Spring-Kontext laufen — die Property braucht
seinen Port. DynamicPropertySource läuft beim Kontextaufbau, also
genau rechtzeitig. */
@JvmStatic
@DynamicPropertySource
fun taigaUrl(registry: DynamicPropertyRegistry) {
startStub()
registry.add("werkbaum.taiga.api-url") { "http://127.0.0.1:${stub.address.port}/api/v1" }
}
}
}
/** Aufgezeichnete Antwortformen (dieselben Formen wie im Client-Unit-Test). */
object TaigaClientTestData {
const val AUTH_OK =
"""{"id": 42, "username": "mi", "full_name": "Michael", "auth_token": "tok-abc123"}"""
const val AUTH_FAIL =
"""{"_error_message": "Username or password does not matches user.", "_error_type": "taiga.base.exceptions.WrongArguments"}"""
const val PROJECTS_OK =
"""[{"id": 7, "name": "Intern", "slug": "mi-intern"}, {"id": 9, "name": "Kunde", "slug": "mi-kunde"}]"""
const val STORY_OK =
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
}
@@ -0,0 +1,51 @@
package de.werkbaum.api
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient
/**
* Ohne konfigurierte Taiga-Instanz ist der Proxy **aus**, nicht kaputt:
* `GET /info` meldet `taiga: false` (der Editor zeigt die Aktionen dann gar
* nicht erst), und ein Aufruf trotzdem antwortet mit 503 statt eines
* nichtssagenden Fehlers (D91).
*/
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@TestPropertySource(
properties = [
// Eigene Datenbank: zweiter Spring-Kontext (siehe MasterPasswordDefaultTest).
"spring.datasource.url=jdbc:h2:mem:editor-taiga-off;" +
"DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
]
)
class TaigaDisabledTest {
@Autowired
private lateinit var client: RestTestClient
@Test
fun `info meldet das fehlende Taiga-Feature`() {
val result = client.get().uri("/api/v1/info").exchange().returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"taiga\":false"
}
@Test
fun `ein Aufruf ohne Konfiguration antwortet mit 503`() {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"geheim"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 503
}
}
@@ -0,0 +1,177 @@
package de.werkbaum.integration.taiga
import com.sun.net.httpserver.HttpServer
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.InetSocketAddress
/**
* Der Taiga-Client gegen **aufgezeichnete Antworten** (Stub-Server im Test),
* nie gegen die Live-Instanz (backend/CLAUDE.md). Die Antwortformen stammen
* aus der Vermessung der Zielinstanz (D91-Nachtrag 1) bzw. der Taiga-API.
*/
class TaigaClientTest {
private fun client() = TaigaClient(TaigaProperties(apiUrl = "http://127.0.0.1:$port/api/v1"))
@BeforeEach
fun reset() {
recorded = null
responseStatus = 200
responseBody = "{}"
}
@Test
fun `login reicht Typ, Benutzername und Passwort durch und liefert die schmale Sitzung`() {
responseBody = AUTH_OK
val session = client().login("mi", "geheim")
session.authToken shouldBe "tok-abc123"
session.userId shouldBe 42L
session.username shouldBe "mi"
session.fullName shouldBe "Michael"
val req = recorded!!
req.path shouldBe "/api/v1/auth"
req.body shouldContain "\"type\":\"ldap\""
req.body shouldContain "\"username\":\"mi\""
req.body shouldContain "\"password\":\"geheim\""
}
@Test
fun `abgelehnte Zugangsdaten (Taiga 400) werden mit Status und Fehlertext durchgereicht`() {
responseStatus = 400
responseBody = AUTH_FAIL
val ex = shouldThrow<TaigaUpstreamException> { client().login("mi", "falsch") }
ex.status shouldBe 400
ex.message shouldContain "does not matches"
}
@Test
fun `projects sendet Bearer-Token, member-Filter und schaltet die Paginierung ab`() {
responseBody = PROJECTS_OK
val projects = client().projects("tok-abc123", 42)
projects.map { it.slug } shouldBe listOf("mi-intern", "mi-kunde")
projects[0].id shouldBe 7L
projects[0].name shouldBe "Intern"
val req = recorded!!
req.path shouldBe "/api/v1/projects"
req.query shouldBe "member=42&order_by=user_order"
req.auth shouldBe "Bearer tok-abc123"
req.noPagination shouldBe "1"
}
@Test
fun `createStory postet project und subject und liefert die Ref`() {
responseStatus = 201
responseBody = STORY_OK
val ticket = client().createStory("tok-abc123", 7, "Backend bauen")
ticket.ref shouldBe 123L
ticket.id shouldBe 1234L
ticket.subject shouldBe "Backend bauen"
val req = recorded!!
req.path shouldBe "/api/v1/userstories"
req.auth shouldBe "Bearer tok-abc123"
req.body shouldContain "\"project\":7"
req.body shouldContain "\"subject\":\"Backend bauen\""
}
@Test
fun `createTask haengt die Task per user_story an ihre Story`() {
responseStatus = 201
responseBody = TASK_OK
val ticket = client().createTask("tok-abc123", 7, "API-Teil", 1234)
ticket.ref shouldBe 124L
recorded!!.path shouldBe "/api/v1/tasks"
recorded!!.body shouldContain "\"user_story\":1234"
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
shouldThrow<TaigaNotConfiguredException> { bare.login("mi", "geheim") }
}
@Test
fun `eine nicht erreichbare Instanz ist ein eigener, benannter Fehler`() {
val dead = TaigaClient(TaigaProperties(apiUrl = "http://127.0.0.1:$deadPort/api/v1"))
shouldThrow<TaigaUnavailableException> { dead.login("mi", "geheim") }
}
@Test
fun `eine Antwort ohne die erwarteten Felder scheitert laut statt still`() {
responseBody = """{"unexpected": true}"""
val ex = shouldThrow<TaigaUnavailableException> { client().login("mi", "geheim") }
ex.message shouldContain "auth_token"
}
data class Recorded(
val path: String,
val query: String?,
val auth: String?,
val noPagination: String?,
val body: String,
)
companion object {
private lateinit var server: HttpServer
private var port = 0
private var deadPort = 0
@Volatile var recorded: Recorded? = null
@Volatile var responseStatus = 200
@Volatile var responseBody = "{}"
/* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder
plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */
const val AUTH_OK =
"""{"id": 42, "username": "mi", "full_name": "Michael", "email": "mi@example.org", "auth_token": "tok-abc123", "roles": ["Product Owner"]}"""
const val AUTH_FAIL =
"""{"_error_message": "Username or password does not matches user.", "_error_type": "taiga.base.exceptions.WrongArguments"}"""
const val PROJECTS_OK =
"""[{"id": 7, "name": "Intern", "slug": "mi-intern", "description": "x"}, {"id": 9, "name": "Kunde", "slug": "mi-kunde", "description": "y"}]"""
const val STORY_OK =
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
@JvmStatic
@BeforeAll
fun startStub() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { ex ->
recorded = Recorded(
path = ex.requestURI.path,
query = ex.requestURI.query,
auth = ex.requestHeaders.getFirst("Authorization"),
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
body = ex.requestBody.readBytes().decodeToString(),
)
val bytes = responseBody.encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(responseStatus, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) }
}
server.start()
port = server.address.port
/* Ein Port, hinter dem sicher nichts lauscht: kurz binden, wieder
freigeben. */
val probe = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
deadPort = probe.address.port
probe.stop(0)
}
@JvmStatic
@AfterAll
fun stopStub() = server.stop(0)
}
}
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-27 ## 2026-08-27
- The backend proxies Taiga through four named endpoints — login, projects, create story, create task; the Taiga API address is server configuration, the token stays in the browser, and `/api/v1/info` says whether the feature is on
- Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards - Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards
- Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned - Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned
- Creating Taiga tickets from nodes recorded as planned: a story per node from the node window, sub-packages as tasks picked in a dialog, through a backend proxy — the ref lands in the line as `#US-123`/`#T-1234`, beside the node id - Creating Taiga tickets from nodes recorded as planned: a story per node from the node window, sub-packages as tasks picked in a dialog, through a backend proxy — the ref lands in the line as `#US-123`/`#T-1234`, beside the node id
+1 -1
View File
@@ -206,7 +206,7 @@
- [ ] #trk.resolve.map: Map the workflow onto the states (S) - [ ] #trk.resolve.map: Map the workflow onto the states (S)
- [?] #trk.write: Write the status back (M) :#trk.resolve - [?] #trk.write: Write the status back (M) :#trk.resolve
- [ ] #trk.create: Create tickets from nodes (XL) - [ ] #trk.create: Create tickets from nodes (XL)
- [ ] #trk.create.proxy: Backend proxy with named endpoints (M) - [x] #trk.create.proxy: Backend proxy with named endpoints (M)
- [ ] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy - [ ] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
- [ ] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project - [ ] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project
- [ ] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login - [ ] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login
+5
View File
@@ -176,6 +176,11 @@ if [ ! -f "$DIR/env" ]; then
# Solange der Hash leer ist, bleibt GET /api/v1/documents gesperrt # Solange der Hash leer ist, bleibt GET /api/v1/documents gesperrt
# (D76-Nachtrag 6). # (D76-Nachtrag 6).
WERKBAUM_MASTER_PASSWORD_HASH= WERKBAUM_MASTER_PASSWORD_HASH=
# Taiga-Proxy (D91): Basis-URL der Taiga-API — nicht des Frontends; bei der
# Zielinstanz liegt sie auf einem eigenen Host. Leer = Feature aus;
# GET /api/v1/info meldet es (taiga).
#WERKBAUM_TAIGA_API_URL=https://plan-api.hostsharing.net/api/v1
ENV ENV
chmod 600 "$DIR/env" chmod 600 "$DIR/env"
echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen," echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen,"