Der adapter-neutrale InitialAdminBootstrapService aus Teil 4.1 und seine drei strukturellen Beziehungen aus Teil 4.2 — Token-Mechanik, PasswordHasher, AdministratorAccountStore — sind beschrieben. Was bleibt, ist die Behandlung der vier konkreten Aufrufkontexte, die diesen Service tatsächlich nutzen. Drei davon sind Nicht-Vaadin-Anbindungen — der REST-Endpunkt im demo-rest, das CLI-Subkommando in derselben Anwendung, der Vaadin-Client als REST-Konsument —, eine ist die zweite Vaadin-SetupView aus dem demo-vaadin-rest-client, die strukturell der ersten aus Teil 4.1 entspricht und sich in einer einzigen Codezeile von ihr unterscheidet.
Sämtliche im Folgenden vorgestellten Quelltexte sind auf GitHub veröffentlicht und
unter https://3g3.eu/vaadin-security abrufbar.
Vier Kapitel führen durch diese Anbindungen. Den Anfang macht der REST-Endpunkt mit seinen zwei Pfaden und der Übersetzung der HTTP-Statuscodes. Es folgt die CLI mit dem init-admin-Subkommando und der Eingabe console.readPassword. Schließlich die zweite Vaadin-Anbindung, die das Vier-Adapter-Bild schließt und die Symmetrie zur in-JVM-Variante explizit macht. Die Bilanz im letzten Kapitel zieht die Verdichtungen aus den vorigen Subteilen zusammen, benennt, was die Architektur leistet und kostet, und führt die Themen auf, die für Teil 5 vorbereitet bleiben.
Erste fremde Anbindung: REST-Endpunkt im demo-rest
Der InitialAdminBootstrapService aus Kapitel 3 liegt seit Kapitel 6 vollständig beschrieben vor uns; was bleibt, sind die vier Aufrufkontexte, die ihn nutzen. Den Anfang macht der REST-Endpunkt im demo-rest, weil er drei Eigenschaften gleichzeitig besitzt: Er ist die einfachste der vier Anbindungen, wenn man Vaadin und CLI als komplexere UI-Anbindungen rechnet. Er ist der erste Adapter, der das sealed InitialAdminCreationResult in ein formales Außenformat übersetzt — in HTTP-Statuscodes und JSON-Body. Und er ist der Adapter, den die zwei Nicht-Vaadin-Konsumenten — die CLI aus Kapitel 8 und die Vaadin-SetupView aus Kapitel 9 — als gemeinsamen Backend-Endpunkt nutzen, sodass alle drei nicht-direkten Anbindungen am Ende dieselbe HTTP-Schnittstelle ansprechen.
Der Endpunkt besteht aus zwei Pfaden. Der Erste ist GET /api/bootstrap/status, ein lesender Aufruf, der den aktuellen Initialisierungsstatus zurückgibt. Der zweite ist die POST-Anfrage an /api/bootstrap/admin, die zum Schreiben des ersten Administrators verwendet wird. Beide Pfade leben in DemoBootstrapHandlers, der seinerseits den BootstrapStateService und den InitialAdminBootstrapService als Konstruktorparameter erhält. Mehr Bestandteile braucht der Adapter nicht.
Der Status-Endpunkt ist erzählerisch wertvoll, weil er eine leak-safe Antwort liefert. Sein Body enthält genau zwei Felder — bootstrapRequired als Boolean und mode als String. Er enthält kein Token. Das ist nicht zufällig: Ein dedizierter Test (BootstrapStatusTest.noTokenField) verifiziert per Reflection, dass der BootstrapStatus-Record im Kern kein Feld mit dem Substring token im Feldnamen enthält, und der Handler liest seine Daten ausschließlich aus diesem Snapshot. Eine künftige Erweiterung, die versehentlich ein Token-Feld einführen wollte, würde am Test scheitern, bevor sie deployed wird. Diese Disziplin macht den Status-Endpunkt für den Vaadin-REST-Client aus Kapitel 9 nutzbar, ohne dass die MyLoginView riskieren müsste, ein Token mitgeliefert zu bekommen.
Der Admin-Endpunkt enthält die eigentliche Logik. Er verarbeitet einen JSON-Body mit den fünf bekannten Feldern, ruft den InitialAdminBootstrapService auf, übersetzt das sechsfache Result in einen HTTP-Status mit Error-Code und schreibt einen kurzen JSON-Body als Antwort. Der Pfad in seiner Gänze:
public void createInitialAdmin(RestRequest request, RestResponse response) {
if (!(request instanceof BodyRestRequest bodyRequest)) {
writeJson(response, 400, Map.of("error", "bad_request"));
return;
}
Map<String, Object> body;
try {
body = DemoJson.decodeObject(bodyRequest.bodyAsUtf8());
} catch (RuntimeException e) {
writeJson(response, 400, Map.of("error", "bad_request"));
return;
}
String token = string(body, "bootstrapToken");
String username = string(body, "username");
String password = string(body, "password");
String displayName = string(body, "displayName");
String email = string(body, "email");
if (token == null || username == null || password == null) {
writeJson(response, 400, Map.of("error", "bad_request"));
return;
}
char[] pwd = password.toCharArray();
InitialAdminCreationResult result = bootstrapService.createInitialAdmin(
new CreateInitialAdminCommand(token, username, pwd, displayName, email));
int status = STATUS_MAPPER.statusFor(result);
String code = STATUS_MAPPER.errorCodeFor(result);
Map<String, Object> payload = new LinkedHashMap<>();
if (result instanceof InitialAdminCreationResult.Created) {
payload.put("status", code);
} else {
payload.put("error", code);
if (result instanceof InitialAdminCreationResult.PasswordPolicyViolation policy) {
payload.put("reason", policy.reason() == null ? "" : policy.reason());
} else if (result instanceof InitialAdminCreationResult.InvalidUsername invalid) {
payload.put("reason", invalid.reason() == null ? "" : invalid.reason());
}
}
writeJson(response, status, payload);
}
Drei Beobachtungen zu dieser Methode. Die erste betrifft die Eingabevalidierung, die strukturell parallel zur Vaadin-SetupView aus Kapitel 2 ausgeführt wird. Beide Adapter prüfen auf leere Pflichtfelder und übergeben anschließend den Rest an den InitialAdminBootstrapService. Der Unterschied: Der REST-Adapter muss zusätzlich gegen einen fehlerhaften Body-Inhalt absichern — wenn der Aufrufer kein gültiges JSON schickt, wenn der Body nicht über BodyRestRequest zugänglich ist, oder wenn die JSON-Struktur nicht zu einem Map<String, Object> passt. Diese Absicherungen sind nicht spektakulär, aber notwendig. In jedem Fall liefert der Adapter 400 bad_request zurück — eine bewusst generische Antwort, die einem Aufrufer mit fehlerhaftem Body keine Hinweise auf das interne Format gibt.
Die zweite Beobachtung betrifft die char[]-Disziplin am REST-Eingang. Der JSON-Body wird als Zeichenkette übergeben; das Passwortfeld wird als String extrahiert. Die Methode wandelt diesen String mit password.toCharArray() in ein char[] um, übergibt es dem Service und überlässt ihm das Wischen — exakt wie in der Vaadin-SetupView aus Kapitel 2. Die String-Instanz aus dem JSON-Parsing lebt weiter, bis der Garbage Collector sie einsammelt; die char[]-Hygiene wirkt nur am freiwillig handelbaren Pfad. Diese Beobachtung wiederholt die in Kapitel 5 ausgesprochene Ehrlichkeit gegenüber den Grenzen der char[]-Disziplin in einer Welt, in der Eingabeschnittstellen Strings liefern.
Die dritte Beobachtung betrifft den JSON-Antwortkörper. Er ist absichtlich knapp gehalten. Bei Erfolg trägt er ein einziges Feld — status: “created”. Bei Misserfolg trägt er zwei Felder — error: “<code>” und optional reason: “…” für die zwei Result-Varianten, die einen Grund mitliefern. Es gibt keine Stacktraces, keine Klassennamen, keine internen Details. Das bootstrapToken-Feld aus dem Request kommt im Response unter keinen Umständen wieder vor, weder bei Erfolg noch bei Token-Ablehnung. Diese Disziplin ist die HTTP-seitige Umsetzung der Spurenarmut aus Kapitel 1.
Die Übersetzung vom sealed InitialAdminCreationResult in HTTP-Statuscodes übernimmt der BootstrapRestStatusMapper, der im security-rest-Modul lebt:
public final class BootstrapRestStatusMapper {
public int statusFor(InitialAdminCreationResult result) {
return switch (result) {
case InitialAdminCreationResult.Created ignored -> 201;
case InitialAdminCreationResult.AlreadyInitialized ignored -> 409;
case InitialAdminCreationResult.InvalidBootstrapToken ignored -> 403;
case InitialAdminCreationResult.PasswordPolicyViolation ignored -> 400;
case InitialAdminCreationResult.InvalidUsername ignored -> 400;
case InitialAdminCreationResult.InternalError ignored -> 500;
};
}
public String errorCodeFor(InitialAdminCreationResult result) {
return switch (result) {
case InitialAdminCreationResult.Created ignored -> "created";
case InitialAdminCreationResult.AlreadyInitialized ignored -> "system_already_initialized";
case InitialAdminCreationResult.InvalidBootstrapToken ignored -> "invalid_bootstrap_token";
case InitialAdminCreationResult.PasswordPolicyViolation ignored -> "password_policy_violation";
case InitialAdminCreationResult.InvalidUsername ignored -> "invalid_username";
case InitialAdminCreationResult.InternalError ignored -> "internal_error";
};
}
}
Sechs Result-Varianten, fünf HTTP-Statuscodes, sechs stabile Error-Codes. Die Statuscode-Wahl ist nicht beliebig: 201 (Created) für die erfolgreiche Anlage einer Ressource, 400 (Bad Request) für clientseitig korrigierbare Eingabefehler — Kennwort zu kurz, Benutzername unzulässig —, 403 (Forbidden) für die Token-Verweigerung, 409 (Conflict) für die AlreadyInitialized-Variante, weil die anzulegende Ressource bereits in einem Zustand ist, der eine Anlage ausschließt, und 500 (Internal Server Error) für unerwartete Fehler. Die zwei 400-Varianten — password_policy_violation und invalid_username — werden im Statuscode nicht unterschieden; sie unterscheiden sich im Error-Code-String, sodass ein anspruchsvollerer Client zwischen ihnen unterscheiden kann, ein einfacherer aber auch ohne diese Unterscheidung sinnvolle Fehlermeldungen anzeigt.
Der Mapper lebt im security-rest-Modul und nicht im demo-rest, weil er adapterübergreifend wiederverwendbar ist. Eine andere REST-basierte Anwendung, die Bootstrap nutzt, würde denselben Mapper verwenden und damit dieselben Status- und Error-Codes erzeugen. Diese Stabilität ist die Voraussetzung dafür, dass der Vaadin-REST-Client aus Kapitel 9 die Antworten verlässlich interpretieren kann — sein eigenes BootstrapResult mit den sealed Varianten Created, AlreadyInitialized, InvalidToken, PolicyViolation, InvalidUsername, TransportError und InternalError ist eine direkte Übersetzung dieser Statuscodes zurück in die Java-Welt.
Der Vaadin-Bezug dieses Kapitels lebt damit doppelt. Erstens ist der Status-Endpunkt der Punkt, an dem die MyLoginView des demo-vaadin-rest-clients ihre Bootstrap-Weiche auslöst — sie ruft bootstrapStatus() auf, das auf diesen Endpunkt zugreift, und leitet bei bootstrapRequired = true zur eigenen SetupView weiter. Zweitens ist der Admin-Endpunkt der Punkt, an dem die Vaadin-SetupView des demo-vaadin-rest-clients ihren Submit-Aufruf landet — eine HTTP-Anfrage, die strukturell identisch mit einem CLI- oder einem direkten curl-Aufruf ist. Dass der Adapter alle drei Aufrufkontexte ohne Differenzierung bedient, ist die Bestätigung der in Kapitel 6 angekündigten Vier-Adapter-Logik.
Damit ist der erste fremde Adapter beschrieben. Was bleibt, sind die zwei Verbleibenden — die CLI und der Vaadin-REST-Client. Den Anfang macht in Kapitel 8 das init-admin-Subkommando der CLI, das denselben REST-Endpunkt aus einer interaktiven Terminaleingabe heraus aufruft.
Zweite fremde Anbindung: CLI mit init-admin
Der dritte Adapter und der zweite fremde Aufrufkontext leben im selben Modul wie der REST-Endpunkt aus Kapitel 7. Der demo-rest enthält neben dem Server eine eigenständige Kommandozeilenanwendung — den DemoRestCli mit seiner zentralen Schleife CliCommandLoop —, die nicht als Testwerkzeug, sondern als gleichberechtigter dritter Adapter konzipiert ist. Sie spricht denselben REST-Endpunkt aus Kapitel 7 mit denselben fünf Eingabefeldern an, übersetzt aber die Mensch-Maschine-Schnittstelle in eine vollständig andere Form: sequenzielle Terminal-Eingabe statt Browser-Form, getipptes Token mit Maskierung statt PasswordField im DOM, Klartext-Antwort im Terminal statt Notification mit anschließender Navigation.
Das init-admin-Subkommando steht in dieser Schleife neben den fünf bekannten Kommandos login, me, operations, call und logout. Es wird interaktiv getippt, wenn der Operator den ersten Administrator anlegen möchte, und das ist nur in der kurzen Zeit zwischen dem Hochfahren des Servers und dem erfolgreichen Bootstrap sinnvoll. Aus einem späteren Aufruf — wenn ein Administrator bereits existiert — ergibt sich derselbe 409-Statuscode, der die Vaadin-SetupView aus Kapitel 2 zur Login-View zurückleiten würde; in der CLI wird er zu einer einzigen Zeile im Terminal: „System already initialized — bootstrap is no longer available.”
Die Methode initAdmin selbst führt vier Aufgaben in Folge aus: die Status-Vorabprüfung, die interaktive Eingabe mit Maskierung sensibler Felder, den HTTP-Aufruf an /api/bootstrap/admin und die Übersetzung des Statuscodes in eine menschenlesbare Antwort. Der vollständige Pfad sieht so aus:
private void initAdmin() throws IOException, InterruptedException {
HttpResponse<String> status = client.bootstrapStatus();
if (status.statusCode() != 200) {
out.println("Could not query bootstrap status: " + status.statusCode());
return;
}
Map<String, Object> body = DemoJson.decodeObject(status.body());
if (!Boolean.TRUE.equals(body.get("bootstrapRequired"))) {
out.println("Bootstrap is not required — an administrator already exists.");
return;
}
out.println("Initial administrator setup. The bootstrap token must be obtained from");
out.println("the server console (transient mode) or the token file (persistent mode).");
String token = readSecret("Bootstrap token");
if (token.isEmpty()) {
out.println("Aborted: bootstrap token must not be empty.");
return;
}
String username = prompt("Admin username [admin]");
if (username.isEmpty()) username = "admin";
char[] pwd = readPassword("New admin password");
if (pwd.length == 0) {
out.println("Aborted: password must not be empty.");
return;
}
char[] confirm = readPassword("Repeat password");
if (!Arrays.equals(pwd, confirm)) {
Arrays.fill(pwd, '\0');
Arrays.fill(confirm, '\0');
out.println("Aborted: passwords do not match.");
return;
}
String displayName = prompt("Display name (optional)");
String email = prompt("Email (optional)");
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("bootstrapToken", token);
payload.put("username", username);
payload.put("password", new String(pwd));
if (!displayName.isEmpty()) payload.put("displayName", displayName);
if (!email.isEmpty()) payload.put("email", email);
try {
HttpResponse<String> response = client.bootstrapAdmin(DemoJson.encode(payload));
switch (response.statusCode()) {
case 201 -> out.println("Administrator created. You can now log in with the chosen password.");
case 409 -> out.println("System already initialized — bootstrap is no longer available.");
case 403 -> out.println("Bootstrap token rejected.");
case 400 -> {
Map<String, Object> error = DemoJson.decodeObject(response.body());
Object reason = error.get("reason");
out.println("Setup rejected: " + error.getOrDefault("error", "bad_request")
+ (reason != null ? " — " + reason : ""));
}
default -> out.println("Unexpected response: " + response.statusCode());
}
} finally {
Arrays.fill(pwd, '\0');
Arrays.fill(confirm, '\0');
}
}
Drei Beobachtungen zu dieser Methode. Die erste betrifft die Status-Vorabprüfung. Bevor irgendeine Eingabe vom Operator angefordert wird, fragt die Methode client.bootstrapStatus() prüft, ob ein Bootstrap überhaupt erforderlich ist. Diese Prüfung ist die CLI-Variante der BeforeEnter-Weiche der Vaadin-SetupView aus Kapitel 2 — sie verhindert, dass der Operator Token, Benutzername und Kennwort eintippt, nur um am Ende eine 409-Ablehnung zu erhalten. Der Unterschied zur Vaadin-Variante: Die Weiche in der CLI muss explizit als HTTP-Aufruf modelliert werden, da keine UI-Routing-Schicht zur Verfügung steht. Die Vaadin-Variante delegiert die Weiche an den Vaadin-Router; die CLI muss sie selbst übernehmen.
Die zweite Beobachtung betrifft die Eingabe selbst, die zwischen prompt, readSecret und readPassword unterschieden wird. Die Hilfsmethode readPassword ist die Stelle, an der die char[]-Disziplin am CLI-Eingang gewahrt wird:
private char[] readPassword(String label) throws IOException {
Console console = System.console();
if (console != null) {
char[] chars = console.readPassword(label + ": ");
return chars == null ? new char[0] : chars;
}
out.print(label + " (input will be visible): ");
out.flush();
String line = reader.readLine();
return line == null ? new char[0] : line.toCharArray();
}
Console.readPassword aus dem JDK liefert die Eingabe direkt als char[] und maskiert die Zeichen während der Eingabe — dies ist der einzige der vier Adapter, der das Token tatsächlich vor dem Auge des Beobachters versteckt, ohne das Browser-DOM zu nutzen. Wenn die CLI ohne TTY läuft — etwa weil sie aus einem Skript heraus gestartet wird oder die Eingabe über eine Pipe kommt —, fällt sie auf die Standardeingabe mit sichtbarem Echo zurück und kündigt diese Degradation explizit mit dem Suffix „(input will be visible)” an. Diese Degradation ist eine bewusste Entscheidung: Sie macht das Subkommando in nicht-interaktiven Umgebungen weiterhin nutzbar — etwa für automatisierte Erstinitialisierungen — ohne die TTY-Variante zu beschädigen.
Die dritte Beobachtung betrifft die char[]-Hygiene über die gesamte Methode hinweg. Drei char[]-Instanzen kommen ins Spiel: das gelesene Kennwort pwd, die Bestätigung confirm und intern in readSecret ein temporäres char[], das sofort in einen String für den Token konvertiert und anschließend gelöscht wird. Die beiden sichtbaren Arrays werden im äußersten Finally-Block der Methode mit Arrays.fill(…, ‘\0’) überschrieben — und zusätzlich beim frühen Abbruch aufgrund nicht übereinstimmender Kennwörter. Die String-Form des Kennworts entsteht erst beim JSON-Encoding des Bodies (new String(pwd)); diese Zeichenfolge lebt weiter, bis der Garbage Collector sie einsammelt, und weist dieselbe Lücke auf wie an der Vaadin-SetupView und am REST-Endpunkt. Die Disziplin ist die strengste, die der Adapter heute leisten kann; sie ist nicht perfekt, aber über alle vier Anbindungen hinweg konsistent.
Drei verschiedene UIs, ein einziger Bootstrap-Kern. Die Vaadin-SetupView aus Kapitel 2 mit ihrem Browser-Formular, der REST-Endpunkt aus Kapitel 7 mit seinem JSON-Body, das CLI-Subkommando aus diesem Kapitel mit seiner Terminal-Eingabe — alle drei tragen am Ende dieselben fünf Felder, dieselbe Validierung, dieselbe char[]-Disziplin. Die Symmetrie ist erzählerisch wertvoll, weil sie den InitialAdminBootstrapService aus Kapitel 3 als adapterneutralen Mittelpunkt bestätigt — ein Konstruktor, drei verschiedene Aufrufkontexte, kein Code im Service, der einen davon erkennt oder bevorzugt.
Was bleibt, ist die vierte Anbindung — die zweite Vaadin-SetupView, die nicht im selben JVM-Prozess wie der Bootstrap-Service läuft, sondern den REST-Endpunkt aus Kapitel 7 als Brücke zum Service nutzt. Diese vierte Anbindung schließt das Vier-Adapter-Bild ab und ist Gegenstand von Kapitel 9.
Vaadin als REST-Client: die vierte Anbindung
Drei Anbindungen sind beschrieben — die in-JVM-Vaadin-SetupView aus Kapitel 2, der REST-Endpunkt aus Kapitel 7, das CLI-Subkommando aus Kapitel 8. Alle drei verwenden dieselben fünf Felder, dieselbe Validierung, dieselbe char[]-Disziplin und rufen am Ende dieselbe createInitialAdmin-Methode des InitialAdminBootstrapServices aus Kapitel 3 auf. Was dieser Reihe noch fehlt, ist die vierte Anbindung — und sie ist erzählerisch die wichtigste, weil sie die Vaadin-Welt ein zweites Mal bemüht, diesmal in einer fundamental anderen Architekturrolle.
Im Modul demo-vaadin-rest-client gibt es eine eigene SetupView, die optisch und funktional kaum von der in-JVM-Variante des demo-vaadin aus Kapitel 2 zu unterscheiden ist. Dieselbe H2-Überschrift „Initial administrator setup”, dieselbe sechsfeldige Form mit tokenField, usernameField, zwei PasswordField-Instanzen und zwei optionalen TextField-Instanzen, derselbe LUMO_PRIMARY-Button. Ein Anwender, der zwischen den beiden Demos wechselte, würde den Unterschied an der UI nicht erkennen — und genau das ist die Pointe. Was die beiden Anbindungen unterscheidet, ist nicht das, was der Anwender sieht, sondern das, was eine einzige Codezeile im Submit-Pfad bewirkt.
Der Submit-Pfad selbst:
private void submit() {
String token = tokenField.getValue();
String username = usernameField.getValue();
String password = passwordField.getValue();
String confirm = confirmField.getValue();
if (token == null || token.isBlank() || username == null || username.isBlank()
|| password == null || password.isEmpty()) {
error("Token, username and password are required.");
return;
}
if (!password.equals(confirm)) {
error("Passwords do not match.");
return;
}
char[] pwd = password.toCharArray();
BootstrapResult result;
try {
result = BackendClientProvider.client().createInitialAdmin(new BootstrapAdminRequest(
token, username, pwd,
blankToNull(displayNameField.getValue()),
blankToNull(emailField.getValue())));
} finally {
Arrays.fill(pwd, '\0');
tokenField.clear();
passwordField.clear();
confirmField.clear();
}
switch (result) {
case BootstrapResult.Created created -> {
success("Administrator '" + created.username() + "' created. Please log in.");
UI.getCurrent().navigate(MyLoginView.class);
}
case BootstrapResult.AlreadyInitialized ignored -> {
info("System already initialized — redirecting to login.");
UI.getCurrent().navigate(MyLoginView.class);
}
case BootstrapResult.InvalidToken ignored -> error("Bootstrap token rejected.");
case BootstrapResult.PolicyViolation policy ->
error(policy.reason() == null || policy.reason().isBlank()
? "Password rejected by policy." : policy.reason());
case BootstrapResult.InvalidUsername invalid ->
error(invalid.reason() == null || invalid.reason().isBlank()
? "Invalid username." : invalid.reason());
case BootstrapResult.TransportError transport ->
error("Backend unreachable. " + transport.message());
case BootstrapResult.InternalError internal ->
error("Internal error during setup. " + internal.message());
}
}
Drei Beobachtungen zu dieser Methode. Die Erste ist die zentrale: die Symmetrie zur in-JVM-Variante aus Kapitel 2. Die Eingabevalidierung ist Zeile für Zeile identisch. Die char[]-Konvertierung ist identisch. Der clear()-Aufruf für die drei sensiblen Felder ist identisch. Die strukturelle Form des Switches über die versiegelten Result-Varianten ist identisch. Was sich unterscheidet, ist eine einzige Codezeile im Submit-Aufruf. In Kapitel 2 lautete sie BootstrapWiring.instance().bootstrapService().createInitialAdmin(…). Hier lautet sie: BackendClientProvider.client().createInitialAdmin(…). Diese eine Zeile ist die gesamte architektonische Differenz zwischen einer Single-JVM-Anwendung und einem Vaadin-Client gegenüber einem separaten Backend.

Abbildung 3
Abbildung 3: Symmetrie der zwei Vaadin-SetupView-Implementierungen. Beide Säulen haben denselben Formaufbau, dieselbe Validierung, dieselbe char[]-Disziplin und dieselbe switch-Struktur. Der einzige Unterschied — die farblich hervorgehobene Codezeile — entscheidet, ob der Bootstrap im selben JVM-Prozess oder gegen ein separates REST-Backend ausgelöst wird.
Die zweite Beobachtung betrifft die char[]-Disziplin in dieser zweiten Anbindung. Sie wird zweimal getragen — einmal hier im SetupView, ein zweites Mal im HttpDemoBackendClient, der die HTTP-Anfrage tatsächlich absendet. Die View wischt das char[] und leert die drei sensiblen Felder im Finally-Block; der HttpDemoBackendClient wischt sein übergebenes char[] ein zweites Mal unmittelbar nach dem JSON-Encoding des Bodies. Diese Doppelung ist nicht redundant, sondern korrekt: Beide Schichten leben in derselben JVM, beide haben Zugriff auf das char[], und beide halten die Disziplin so weit wie möglich. Die String-Form für das JSON-Encoding entsteht erneut als unvermeidbare Lücke, die dieselbe ist wie in der in-JVM-Variante und am REST-Endpunkt aus Kapitel 7.
Die dritte Beobachtung betrifft den BeforeEnter-Observer. Während die in-JVM-Variante aus Kapitel 2 den BootstrapStateService direkt aufruft, muss diese zweite Variante einen REST-Aufruf an /api/bootstrap/status vornehmen, um die Weiche zur Login-View zu stellen. Dieser Aufruf kann scheitern — wenn das Backend nicht erreichbar ist —, und die View muss diesen Fall sauber behandeln, statt mit einer leeren oder fehlerhaften Setup-Form zu erscheinen. Der entsprechende BeforeEnter zeigt diese Disziplin: Er fängt eine RuntimeException ab und zeigt eine Notification anstelle einer Weiterleitung. Die View ist damit gegen Backend-Ausfälle resilient — eine Eigenschaft, die die in-JVM-Variante nicht braucht, weil dort kein Netzwerkpfad zwischen View und Service besteht.
Das Result-Vokabular ist die zweite Stelle, an der sich die beiden Vaadin-Anbindungen strukturell unterscheiden. Während die in-JVM-Variante das versiegelte InitialAdminCreationResult aus Kapitel 3 mit seinen sechs Varianten direkt konsumiert, hat der demo-vaadin-rest-client ein eigenes versiegeltes BootstrapResult mit sieben Varianten:
public sealed interface BootstrapResult
permits BootstrapResult.Created,
BootstrapResult.AlreadyInitialized,
BootstrapResult.InvalidToken,
BootstrapResult.PolicyViolation,
BootstrapResult.InvalidUsername,
BootstrapResult.TransportError,
BootstrapResult.InternalError {
record Created(String username) implements BootstrapResult {}
record AlreadyInitialized() implements BootstrapResult {}
record InvalidToken() implements BootstrapResult {}
record PolicyViolation(String reason) implements BootstrapResult {}
record InvalidUsername(String reason) implements BootstrapResult {}
record TransportError(String message) implements BootstrapResult {}
record InternalError(String message) implements BootstrapResult {}
}
Sechs der sieben Varianten entsprechen direkt den Varianten von InitialAdminCreationResult; sie unterscheiden sich nur in den Namen — InvalidToken statt InvalidBootstrapToken, PolicyViolation statt PasswordPolicyViolation. Die siebte Variante ist neu: TransportError für Fälle, in denen das Backend nicht erreichbar ist, der HTTP-Aufruf in einen Timeout läuft oder das Netzwerk einen anderen Ausfall erleidet. Diese Variante existiert in der in-JVM-Welt nicht, weil dort keine Netzwerkschicht zwischen View und Service liegt; sie ist die spezifische Erweiterung, die ein REST-Konsument leisten muss.
Die Übersetzung der HTTP-Statuscodes aus Kapitel 7 in dieses BootstrapResult übernimmt der HttpDemoBackendClient. Er ist die spiegelverkehrte Operation des BootstrapRestStatusMapper aus Kapitel 7: Was dort ein InitialAdminCreationResult zu einem Statuscode macht, übersetzt hier ein Statuscode in eine BootstrapResult-Variante. 201 wird zu Created, 409 zu AlreadyInitialized, 403 zu InvalidToken, 400 mit Error-Code password_policy_violation zu PolicyViolation, 400 mit Error-Code invalid_username zu InvalidUsername, 500 zu InternalError und ein Netzwerkfehler zu TransportError. Die zwei Mapper an den beiden Enden der HTTP-Verbindung tragen damit dieselbe Information — der Server-seitige Mapper kodiert sie, der Client-seitige Mapper dekodiert sie — und die SetupView konsumiert das Resultat mit demselben strukturellen Switch, den die in-JVM-Variante aus Kapitel 2 schon trug.
Damit ist das Vier-Adapter-Bild geschlossen. Die SetupView aus dem demo-vaadin ruft den InitialAdminBootstrapService direkt im selben JVM-Prozess auf. Der REST-Endpunkt aus dem demo-rest ruft denselben Service über einen von außen angesprochenen HTTP-Pfad auf. Das CLI-Subkommando aus dem demo-rest ruft denselben REST-Endpunkt in einer interaktiven Schleife auf. Die SetupView im demo-vaadin-rest-client ruft denselben REST-Endpunkt aus einer Vaadin-UI heraus. Vier Aufrufkontexte, drei Anbindungsformen — direkt, REST-direkt, REST-vermittelt —, eine einzige Service-Methode, die die fachliche Wahrheit trägt.
Was bleibt, ist die Bilanz. Was diese Vier-Adapter-Architektur leistet, was sie kostet, welche Eigenschaften sie für die Bibliothek bedeuten und welche Themen sie für Teil 5 bewusst offen lässt — das fasst Kapitel 10 zusammen.
Bilanz: vier Adapter, ein Kern, und was Teil 5 vorbereitet
Was Teil 4 zu zeigen hatte, liegt vollständig vor uns. Die Diagnose der Bootstrap-Lücke aus Kapitel 1 hat vier Sicherheitseigenschaften benannt — Einmaligkeit, Token-Authentizität, Race-Sicherheit, Spurenarmut —, und die folgenden acht Kapitel haben gezeigt, wie eine Architektur diese Eigenschaften gleichzeitig trägt, ohne dass auch nur ein Adapter sie für sich allein leistet. Die vier Aufrufkontexte — die in-JVM-Vaadin-SetupView aus Kapitel 2, der REST-Endpunkt aus Kapitel 7, das CLI-Subkommando aus Kapitel 8, die zweite Vaadin-SetupView als REST-Konsument aus Kapitel 9 — kennen die vier Eigenschaften nicht. Sie tragen jeweils auf ihre eigene Weise zur Eingabe und Ausgabe bei und delegieren den eigentlichen Schutz an den InitialAdminBootstrapService aus Kapitel 3.

Abbildung 4
Abbildung 4: Die Vier-Adapter-Architektur in einem Bild. Im Zentrum der InitialAdminBootstrapService mit seinen fünf Pflichtkomponenten; oben die vier Aufrufkontexte mit ihren Aufrufpfaden — zwei Vaadin-Anbindungen (blau), der REST-Endpunkt (orange) und das CLI-Subkommando (grün). Drei der vier Adapter erreichen den Service über den REST-Endpunkt als Zwischenstation, einer direkt im selben JVM-Prozess.
Das Bild der Bibliothek lässt sich an dieser Stelle in einer Reihe von Verdichtungen zusammenfassen, die sich im Laufe der vorigen Kapitel gebildet haben. Vier Adapter, drei Anbindungsformen, drei AdministratorAccountStore-Konsumenten, zwei Implementierungen, ein Kern. Sechs InitialAdminCreationResult-Varianten, fünf HTTP-Statuscodes, sechs stabile Error-Codes, sieben BootstrapResult-Varianten auf Client-Seite. Drei Arrays.fill-Aufrufe für die char[]-Disziplin, drei clear()-Aufrufe an den Vaadin-Eingabefeldern, eine Console.readPassword-Maskierung am Terminal. Diese Zahlen sind nicht beliebig; sie sind die Buchhaltung einer Architektur, die mit wenigen Bestandteilen viele Kontexte trägt und in jedem Kontext dieselbe Wahrheit repräsentiert.
Was diese Architektur leistet, ist dreifach. Sie macht den Bootstrap-Service zu einer austauschbaren Mitte: Eine andere Anwendung kann eigene AdministratorAccountStore-Implementierungen einbringen, ohne den Kern zu ändern, und beliebig viele Adapter über denselben Kern legen, weil die ReentrantLock-Disziplin im Kern alle Adapter gleichermaßen schützt. Sie macht die char[]-Hygiene zu einer durchgehenden Linie: Vom Vaadin-PasswordField über den CreateInitialAdminCommand und den Pbkdf2PasswordHasher bis in den PBEKeySpec des JDK trägt jede Schicht dieselbe Disziplin so weit, wie sie sie tragen kann — und benennt die Lücke dort, wo sie an die Grenzen der stringbasierten Eingabe-APIs stößt. Sie macht das versiegelte Result-Vokabular zu einem statischen Vertrag: Eine künftige Erweiterung um eine siebte Variante würde sechs Switch-Statements gleichzeitig zu einem Compilerfehler führen, und die Bibliothek bemerkt damit Vertragsverletzungen vor dem Deployment.
Was diese Architektur kostet, ist ehrlich zu nennen. Der Kern und der REST-Adapter haben jeweils ihren eigenen Mapper — BootstrapRestStatusMapper und die in HttpDemoBackendClient lebende umgekehrte Operation —, und beide Mapper kodieren dieselbe Information an beiden Enden derselben HTTP-Verbindung. Eine Veränderung im Kern, etwa eine zusätzliche Variante, müsste an drei Stellen gleichzeitig nachgezogen werden: am sealed InitialAdminCreationResult, am server-seitigen Mapper und am client-seitigen BootstrapResult mit seiner siebten TransportError-Variante. Der Compiler hilft an zwei dieser drei Stellen; an der dritten — der HTTP-Statuscode-Decodierung im HttpDemoBackendClient — bleibt die Disziplin Aufgabe der Tests. Diese Verdoppelung ist der Preis für die strikte Trennung zwischen Server und Client, die in einer einzigen Vaadin-JVM nicht bestehen würde.
Eine weitere Stelle, an der die Architekturdisziplin verlangt wird, ist die Einhaltung der Vertraulichkeit bei der Implementierung des AdministratorAccountStore. Beide gezeigten Implementierungen — VaadinAdministratorAccountStore und DemoAdministratorAccountStore — müssen das übergebene passwordHash unverändert speichern; ein zweites Hashing wäre eine schwerwiegende Inkonsistenz, die die laufende Authentifizierung scheitern lassen würde. Der Compiler kann diese Eigenschaft nicht prüfen; sie lebt im Javadoc und im Vertrauen darauf, dass der Implementierer des Adapters den Vertrag einhält. Eine künftige Verbesserung könnte hier eine HashedPassword-Wrapper-Klasse einführen, deren Konstruktor den Hash-Format-String validiert und ein zweites Hashing typsicher ausschließt; diese Verbesserung gehört in die Tagesordnung künftiger Iterationen.
Was Teil 4 bewusst nicht behandelt hat, gehört zur Tagesordnung von Teil 5. Vier Themen sind hier zu nennen. Erstens die Klartext-Authentifizierung der vorpopulierten Demo-Benutzer-Editor- und Viewer. Sie liegt jenseits der Bootstrap-Mechanik, weil Bootstrap nur den ersten Administrator betrifft; sie ist aber strukturell dieselbe Frage — der PasswordHasher aus Kapitel 5 sollte auch die laufende Authentifizierung unterstützen, sodass die Klartext-Pfade vollständig verschwinden. Zweitens: das fehlende Audit der Bootstrap-Aktion. Heute schreibt der InitialAdminBootstrapService einen einzigen Log-Eintrag im Erfolgsfall — Teil 5 wird ein strukturiertes Audit-Log einführen, das sowohl erfolgreiche Anlagen als auch verworfene Token-Versuche festhält, ohne das Token selbst zu protokollieren. Drittens: der fehlende Brute-Force-Schutz am Bootstrap-Endpunkt. Mit etwa 100 Bit-Token-Entropie ist ein erfolgreicher Brute-Force-Angriff praktisch ausgeschlossen, aber ein angemessen konfiguriertes Rate-Limit würde verhindern, dass ein Angreifer im Gigabyte-Maßstab Logs erzeugt und damit ein Audit überlastet. Viertens das Multi-Admin-Onboarding — die Frage, wie nach dem ersten Administrator weitere Administratoren angelegt werden. Diese Frage ist heute bewusst nicht gelöst, weil sie eine Authentifizierungsschicht voraussetzt, die ein angemeldetes Administratorkonto nutzt, um andere Administratoren anzulegen. Sie ist die natürliche Fortsetzung der Bootstrap-Mechanik und wird in Teil 5 eine eigene Behandlung erhalten.
Damit endet Teil 4. Was Teil 1 als View-Schutz begonnen, Teil 2 als Trennung zwischen Kern und Adapter ausgebaut und Teil 3 als REST-Anbindung ergänzt hat, hat hier seine vierte Schicht erhalten — den ersten Anwender, der die Anwendung jenseits aller vorpopulierten Demo-Daten betreten kann. Was bleibt, ist die Frage, wie diese Architektur produktionstauglich wird. Sie wird in Teil 5 mit Audit, Brute-Force-Schutz, Session-Verwaltung und der konsequenten Eliminierung der letzten Klartext-Pfade beantwortet.
Bis dahin: Happy Coding.
— Sven