diff --git a/CLAUDE.md b/CLAUDE.md index 580ab363..d1143786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,19 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/` ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup) +## Regenerating OpenAPI schema (SPA types) + +After any change to REST controller paths, request/response DTOs, or `@PathVariable`/`@RequestParam`/`@RequestBody` signatures, regenerate the TypeScript types the SPA consumes. Required for every controller-level change. + +```bash +# Backend must be running on :8081 +cd ui && npm run generate-api:live # fetches fresh openapi.json AND regenerates schema.d.ts +# OR, if openapi.json was updated by other means: +cd ui && npm run generate-api # regenerates schema.d.ts from existing openapi.json +``` + +After regeneration, `ui/src/api/schema.d.ts` and `ui/src/api/openapi.json` will update. The TypeScript compiler then surfaces every SPA call site that needs updating — fix all compile errors before testing in the browser. Commit the regenerated files with the controller change. + ## Maintaining .claude/rules/ When adding, removing, or renaming classes, controllers, endpoints, UI components, or metrics, update the corresponding `.claude/rules/` file as part of the same change. The rule files are the class/API map that future sessions rely on — stale rules cause wrong assumptions. Treat rule file updates like updating an import: part of the change, not a separate task. @@ -78,7 +91,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component # GitNexus — Code Intelligence -This project is indexed by GitNexus as **cameleer-server** (6364 symbols, 16045 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **cameleer-server** (6404 symbols, 16118 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java index b99820d6..99dd3c94 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java @@ -3,10 +3,14 @@ package com.cameleer.server.app.config; import com.cameleer.server.app.analytics.UsageTrackingInterceptor; import com.cameleer.server.app.interceptor.AuditInterceptor; import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor; +import com.cameleer.server.app.web.EnvironmentPathResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + /** * Web MVC configuration. */ @@ -16,13 +20,21 @@ public class WebConfig implements WebMvcConfigurer { private final ProtocolVersionInterceptor protocolVersionInterceptor; private final AuditInterceptor auditInterceptor; private final UsageTrackingInterceptor usageTrackingInterceptor; + private final EnvironmentPathResolver environmentPathResolver; public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor, AuditInterceptor auditInterceptor, - @org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) { + @org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor, + EnvironmentPathResolver environmentPathResolver) { this.protocolVersionInterceptor = protocolVersionInterceptor; this.auditInterceptor = auditInterceptor; this.usageTrackingInterceptor = usageTrackingInterceptor; + this.environmentPathResolver = environmentPathResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(environmentPathResolver); } @Override diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index 0acf9a0b..8dfb164c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -119,11 +119,37 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") .requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") - // Application config endpoints + // Application config endpoints (legacy flat shape — removed as controllers migrate to /environments/{env}/...) .requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") .requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN") - // Read-only data endpoints — viewer+ + // Agent-authoritative config (post-migration split from /api/v1/config/{app}) + .requestMatchers(HttpMethod.GET, "/api/v1/agents/config").hasRole("AGENT") + + // Env-scoped config & settings (specific rules BEFORE the generic /apps/** OPERATOR rule) + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/processor-routes").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/apps/*/config").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/environments/*/apps/*/config/test-expression").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers("/api/v1/environments/*/app-settings").hasRole("ADMIN") + .requestMatchers("/api/v1/environments/*/apps/*/settings").hasRole("ADMIN") + + // Env-scoped data reads (executions/stats/logs/routes/agents/diagrams) + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/errors/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/attributes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/logs").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/agents/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + + // Env-scoped app & deployment management (OPERATOR+) — catch-all for /environments/*/apps/** + .requestMatchers("/api/v1/environments/*/apps/**").hasAnyRole("OPERATOR", "ADMIN") + + // Read-only data endpoints — viewer+ (legacy flat shape — removed per-wave) .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") @@ -132,7 +158,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") - // Runtime management (OPERATOR+) + // Runtime management (OPERATOR+) — legacy flat shape .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") // Admin endpoints diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvPath.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvPath.java new file mode 100644 index 00000000..17eed7a5 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvPath.java @@ -0,0 +1,21 @@ +package com.cameleer.server.app.web; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Injects the {@link com.cameleer.server.core.runtime.Environment} identified by the + * {@code {envSlug}} path variable. Returns 404 if the slug does not exist. + *

+ * Use on handlers under {@code /api/v1/environments/{envSlug}/...}: + *

{@code
+ * @GetMapping("/api/v1/environments/{envSlug}/apps")
+ * public List list(@EnvPath Environment env) { ... }
+ * }
+ */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface EnvPath { +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvironmentPathResolver.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvironmentPathResolver.java new file mode 100644 index 00000000..4ac19cb4 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/EnvironmentPathResolver.java @@ -0,0 +1,61 @@ +package com.cameleer.server.app.web; + +import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.runtime.EnvironmentRepository; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.Map; + +/** + * Resolves the {@code {envSlug}} path variable into an {@link Environment} for handlers + * annotated with {@link EnvPath}. Validates the slug exists in the repository and rejects + * unknown slugs with 404. + *

+ * Paired with {@code SecurityConfig} role rules, this guarantees every env-scoped data + * endpoint receives a real, authorized env without per-handler boilerplate. + */ +@Component +public class EnvironmentPathResolver implements HandlerMethodArgumentResolver { + + static final String ENV_SLUG_VARIABLE = "envSlug"; + + private final EnvironmentRepository environments; + + public EnvironmentPathResolver(EnvironmentRepository environments) { + this.environments = environments; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(EnvPath.class) + && Environment.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest request, + WebDataBinderFactory binderFactory) { + @SuppressWarnings("unchecked") + Map vars = (Map) request.getAttribute( + HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST); + String slug = vars != null ? vars.get(ENV_SLUG_VARIABLE) : null; + if (slug == null || slug.isBlank()) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Handler uses @EnvPath but path has no {envSlug} variable"); + } + return environments.findBySlug(slug) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "Unknown environment: " + slug)); + } +}