feat: zero-config first-run experience with Logto bootstrap
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 37s

- logto-bootstrap.sh: API-driven init script that creates SPA app,
  M2M app, and default user (camel/camel) via Logto Management API.
  Reads m-default secret from DB, then removes seeded apps with
  known secrets (security hardening). Idempotent.
- PublicConfigController: /api/config public endpoint serves Logto
  client ID from bootstrap output file (runtime, not build-time)
- Frontend: LoginPage + CallbackPage fetch config from /api/config
  instead of import.meta.env (fixes Vite build-time baking issue)
- Docker Compose: logto-bootstrap init service with health-gated
  dependency chain, shared volume for bootstrap config
- SecurityConfig: permit /api/config without auth

Flow: docker compose up → bootstrap creates apps/user → SPA fetches
config → login page shows → sign in with Logto → camel/camel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 00:22:22 +02:00
parent cda7dfbaa7
commit 021b056bce
9 changed files with 371 additions and 28 deletions

View File

@@ -0,0 +1,61 @@
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.util.Map;
@RestController
public class PublicConfigController {
private static final Logger log = LoggerFactory.getLogger(PublicConfigController.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
@Value("${cameleer.identity.logto-endpoint:}")
private String logtoEndpoint;
@Value("${cameleer.identity.spa-client-id:}")
private String spaClientId;
private final ObjectMapper objectMapper = new ObjectMapper();
@GetMapping("/api/config")
public Map<String, String> config() {
String clientId = spaClientId;
// Fall back to bootstrap file if env var not set
if (clientId == null || clientId.isEmpty()) {
clientId = readBootstrapClientId();
}
// Use external Logto endpoint for browser redirects
String endpoint = logtoEndpoint;
if (endpoint == null || endpoint.isEmpty()) {
endpoint = "http://localhost:3001";
}
return Map.of(
"logtoEndpoint", endpoint,
"logtoClientId", clientId != null ? clientId : ""
);
}
private String readBootstrapClientId() {
try {
File file = new File(BOOTSTRAP_FILE);
if (file.exists()) {
JsonNode node = objectMapper.readTree(file);
return node.has("spaClientId") ? node.get("spaClientId").asText() : "";
}
} catch (Exception e) {
log.warn("Failed to read bootstrap config: {}", e.getMessage());
}
return "";
}
}

View File

@@ -49,6 +49,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll()
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
.anyRequest().authenticated()

View File

@@ -37,6 +37,7 @@ cameleer:
logto-endpoint: ${LOGTO_ENDPOINT:}
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
runtime:
max-jar-size: 209715200
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}