From e388edeb1e138c76565b20ea5398b32fb314021e Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sat, 28 Mar 2026 18:13:45 +0200 Subject: [PATCH 01/47] chore: rebase sanitized npm-fix onto master --- .claude/agents/senior-enterprise-java.md | 144 ++++ .claude/rules/java/coding-style.md | 114 ++++ .claude/rules/java/hooks.md | 18 + .claude/rules/java/patterns.md | 146 ++++ .claude/rules/java/security.md | 100 +++ .claude/rules/java/testing.md | 131 ++++ .claude/settings.json | 16 + .claude/skills/backend-patterns/SKILL.md | 598 +++++++++++++++++ .claude/skills/debug/SKILL.md | 6 + .claude/skills/fix-java/SKILL.md | 18 + .claude/skills/java-coding-standards/SKILL.md | 147 +++++ .claude/skills/springboot-patterns/SKILL.md | 314 +++++++++ .claude/skills/springboot-tdd/SKILL.md | 158 +++++ .../skills/springboot-verification/SKILL.md | 231 +++++++ .claudignore | 56 ++ .githooks/pre-commit | 4 + .gitignore | 8 +- .run/Application.run.xml | 13 + .run/ApplicationLocal.run.xml | 14 + .serena/.gitignore | 2 + .../memories/coding_style_and_conventions.md | 10 + .serena/memories/project_overview.md | 15 + .serena/memories/suggested_commands.md | 26 + .serena/memories/task_completion_checklist.md | 8 + .serena/project.yml | 152 +++++ AGENTS.md | 622 +----------------- ARCHITECTURE.md | 358 ++++++++++ CLAUDE.md | 31 + CODE_STYLE.md | 182 +++++ DEPLOYMENT.md | 12 +- MEMORY.md | 25 + Makefile | 98 +++ README.md | 29 +- cli/TESTING.md | 110 +++- cli/bin/setup.js | 164 +++-- cli/package.json | 2 +- cli/templates/application-simple-both.yml | 68 ++ cli/templates/application-simple-ollama.yml | 55 ++ .../application-simple-openrouter.yml | 50 ++ ...example => application-simple.yml.example} | 36 +- cli/templates/docker-compose.yml | 28 +- cli/templates/logstash.conf | 19 + docker-compose.yml | 2 +- docs/configuration-profiles.md | 167 +++++ docs/rag-logic.md | 256 +++++++ docs/springai-autoconfig-research.md | 124 ++++ docs/usecases/auto-mode-model-selection.md | 280 ++++++++ docs/usecases/forwarded-message.md | 154 +++++ docs/usecases/image-pdf-vision-cache.md | 105 +++ docs/usecases/text-pdf-rag.md | 110 ++++ opendaimon-app/pom.xml | 20 +- .../AutoModeModelSelectionFixtureIT.java | 170 +++++ .../it/fixture/ForwardedMessageFixtureIT.java | 175 +++++ .../fixture/ImagePdfVisionCacheFixtureIT.java | 210 ++++++ .../it/fixture/TextPdfRagFixtureIT.java | 214 ++++++ .../fixture/config/TelegramFixtureConfig.java | 347 ++++++++++ .../src/main/resources/application-local.yml | 14 +- .../src/main/resources/application.yml | 6 + .../src/main/resources/logback-spring.xml | 64 +- ...SpringAIGatewayStreamingRealContextIT.java | 25 +- .../src/test/resources/application-test.yml | 5 + .../opendaimon/common/SupportedLanguages.java | 17 + .../service/MessageLocalizationService.java | 4 +- .../MessageLocalizationServiceTest.java | 4 +- .../rest/exception/RestExceptionHandler.java | 4 +- .../rest/handler/RestChatHandlerSupport.java | 4 +- .../handler/RestChatHandlerSupportTest.java | 11 +- opendaimon-spring-ai/RAG_BEHAVIOR.md | 157 +++++ opendaimon-spring-ai/SPRING_AI_MODULE.md | 5 +- .../ai/springai/config/RAGAutoConfig.java | 105 ++- .../ai/springai/config/RAGProperties.java | 4 + .../springai/config/SpringAIAutoConfig.java | 36 +- .../memory/SummarizingChatMemory.java | 31 +- .../ai/springai/rag/FileRAGService.java | 5 +- .../opendaimon/ai/springai/retry/README.md | 4 +- .../service/DocumentProcessingService.java | 47 ++ .../springai/service/SpringAIChatService.java | 30 + .../ai/springai/service/SpringAIGateway.java | 115 +++- .../springai/service/SpringAIModelType.java | 4 + .../service/SpringAIPromptFactory.java | 129 +++- .../ai/springai/SpringAIOllamaDnsIT.java | 2 +- .../ai/springai/config/ProviderConfigIT.java | 281 ++++++++ .../ai/springai/config/RAGAutoConfigIT.java | 458 +++++++++++++ .../SpringAIAutoConfigConditionTest.java | 87 +++ .../SpringAIGatewayDocumentRagTest.java | 52 +- .../service/SpringAIPromptFactoryTest.java | 83 +++ .../test/resources/application-rag-test.yml | 48 ++ .../resources/fixtures/rag/image-only.pdf | Bin 0 -> 229386 bytes .../resources/fixtures/rag/text-based.pdf | Bin 0 -> 18810 bytes opendaimon-telegram/TELEGRAM_MODULE.md | 2 +- .../opendaimon/telegram/TelegramBot.java | 106 ++- .../telegram/command/TelegramCommand.java | 2 + .../impl/LanguageTelegramCommandHandler.java | 24 +- .../service/TelegramBotMenuService.java | 11 +- .../telegram/service/TelegramUserService.java | 15 +- .../resources/messages/telegram_en.properties | 3 + .../resources/messages/telegram_ru.properties | 3 + .../opendaimon/telegram/TelegramBotTest.java | 416 ++++++++++++ .../ai/ui/controller/UIAuthController.java | 4 +- scripts/check-commit-guards.sh | 68 ++ 100 files changed, 8293 insertions(+), 874 deletions(-) create mode 100644 .claude/agents/senior-enterprise-java.md create mode 100644 .claude/rules/java/coding-style.md create mode 100644 .claude/rules/java/hooks.md create mode 100644 .claude/rules/java/patterns.md create mode 100644 .claude/rules/java/security.md create mode 100644 .claude/rules/java/testing.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/backend-patterns/SKILL.md create mode 100644 .claude/skills/debug/SKILL.md create mode 100644 .claude/skills/fix-java/SKILL.md create mode 100644 .claude/skills/java-coding-standards/SKILL.md create mode 100644 .claude/skills/springboot-patterns/SKILL.md create mode 100644 .claude/skills/springboot-tdd/SKILL.md create mode 100644 .claude/skills/springboot-verification/SKILL.md create mode 100644 .claudignore create mode 100755 .githooks/pre-commit create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/coding_style_and_conventions.md create mode 100644 .serena/memories/project_overview.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion_checklist.md create mode 100644 .serena/project.yml create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 CODE_STYLE.md create mode 100644 MEMORY.md create mode 100644 Makefile create mode 100644 cli/templates/application-simple-both.yml create mode 100644 cli/templates/application-simple-ollama.yml create mode 100644 cli/templates/application-simple-openrouter.yml rename cli/templates/{application-local.yml.example => application-simple.yml.example} (70%) create mode 100644 cli/templates/logstash.conf create mode 100644 docs/configuration-profiles.md create mode 100644 docs/rag-logic.md create mode 100644 docs/springai-autoconfig-research.md create mode 100644 docs/usecases/auto-mode-model-selection.md create mode 100644 docs/usecases/forwarded-message.md create mode 100644 docs/usecases/image-pdf-vision-cache.md create mode 100644 docs/usecases/text-pdf-rag.md create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/AutoModeModelSelectionFixtureIT.java create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/ForwardedMessageFixtureIT.java create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/ImagePdfVisionCacheFixtureIT.java create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/TextPdfRagFixtureIT.java create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/SupportedLanguages.java create mode 100644 opendaimon-spring-ai/RAG_BEHAVIOR.md create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/config/ProviderConfigIT.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/config/RAGAutoConfigIT.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfigConditionTest.java create mode 100644 opendaimon-spring-ai/src/test/resources/application-rag-test.yml create mode 100644 opendaimon-spring-ai/src/test/resources/fixtures/rag/image-only.pdf create mode 100644 opendaimon-spring-ai/src/test/resources/fixtures/rag/text-based.pdf create mode 100755 scripts/check-commit-guards.sh diff --git a/.claude/agents/senior-enterprise-java.md b/.claude/agents/senior-enterprise-java.md new file mode 100644 index 00000000..ea371e63 --- /dev/null +++ b/.claude/agents/senior-enterprise-java.md @@ -0,0 +1,144 @@ +--- +name: senior-enterprise-java +description: "for most project tasks" +model: sonnet +color: blue +memory: project +--- + +You are a senior Java developer, detail-oriented, building applications with rigorous architecture for multi-module enterprise projects. + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `./.claude/agent-memory/senior-enterprise-java/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. + Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + user: yeah the single bundled PR was the right call here, splitting this one would've just been churn + assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{memory name}} +description: {{one-line description — used to decide relevance in future conversations, so be specific}} +type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} +``` + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When memories seem relevant, or the user references prior-conversation work. +- You MUST access memory when the user explicitly asks you to check, recall, or remember. +- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content. +- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it. + +## Before recommending from memory + +A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it: + +- If the memory names a file path: check the file exists. +- If the memory names a function or flag: grep for it. +- If the user is about to act on your recommendation (not just asking about history), verify first. + +"The memory says X exists" is not the same as "X exists now." + +A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. + +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you save new memories, they will appear here. diff --git a/.claude/rules/java/coding-style.md b/.claude/rules/java/coding-style.md new file mode 100644 index 00000000..d20d5ab6 --- /dev/null +++ b/.claude/rules/java/coding-style.md @@ -0,0 +1,114 @@ +--- +paths: + - "**/*.java" +--- +# Java Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Java-specific content. + +## Formatting + +- **google-java-format** or **Checkstyle** (Google or Sun style) for enforcement +- One public top-level type per file +- Consistent indent: 2 or 4 spaces (match project standard) +- Member order: constants, fields, constructors, public methods, protected, private + +## Immutability + +- Prefer `record` for value types (Java 16+) +- Mark fields `final` by default — use mutable state only when required +- Return defensive copies from public APIs: `List.copyOf()`, `Map.copyOf()`, `Set.copyOf()` +- Copy-on-write: return new instances rather than mutating existing ones + +```java +// GOOD — immutable value type +public record OrderSummary(Long id, String customerName, BigDecimal total) {} + +// GOOD — final fields, no setters +public class Order { + private final Long id; + private final List items; + + public List getItems() { + return List.copyOf(items); + } +} +``` + +## Naming + +Follow standard Java conventions: +- `PascalCase` for classes, interfaces, records, enums +- `camelCase` for methods, fields, parameters, local variables +- `SCREAMING_SNAKE_CASE` for `static final` constants +- Packages: all lowercase, reverse domain (`com.example.app.service`) + +## Modern Java Features + +Use modern language features where they improve clarity: +- **Records** for DTOs and value types (Java 16+) +- **Sealed classes** for closed type hierarchies (Java 17+) +- **Pattern matching** with `instanceof` — no explicit cast (Java 16+) +- **Text blocks** for multi-line strings — SQL, JSON templates (Java 15+) +- **Switch expressions** with arrow syntax (Java 14+) +- **Pattern matching in switch** — exhaustive sealed type handling (Java 21+) + +```java +// Pattern matching instanceof +if (shape instanceof Circle c) { + return Math.PI * c.radius() * c.radius(); +} + +// Sealed type hierarchy +public sealed interface PaymentMethod permits CreditCard, BankTransfer, Wallet {} + +// Switch expression +String label = switch (status) { + case ACTIVE -> "Active"; + case SUSPENDED -> "Suspended"; + case CLOSED -> "Closed"; +}; +``` + +## Optional Usage + +- Return `Optional` from finder methods that may have no result +- Use `map()`, `flatMap()`, `orElseThrow()` — never call `get()` without `isPresent()` +- Never use `Optional` as a field type or method parameter + +```java +// GOOD +return repository.findById(id) + .map(ResponseDto::from) + .orElseThrow(() -> new OrderNotFoundException(id)); + +// BAD — Optional as parameter +public void process(Optional name) {} +``` + +## Error Handling + +- Prefer unchecked exceptions for domain errors +- Create domain-specific exceptions extending `RuntimeException` +- Avoid broad `catch (Exception e)` unless at top-level handlers +- Include context in exception messages + +```java +public class OrderNotFoundException extends RuntimeException { + public OrderNotFoundException(Long id) { + super("Order not found: id=" + id); + } +} +``` + +## Streams + +- Use streams for transformations; keep pipelines short (3-4 operations max) +- Prefer method references when readable: `.map(Order::getTotal)` +- Avoid side effects in stream operations +- For complex logic, prefer a loop over a convoluted stream pipeline + +## References + +See skill: `java-coding-standards` for full coding standards with examples. +See skill: `jpa-patterns` for JPA/Hibernate entity design patterns. diff --git a/.claude/rules/java/hooks.md b/.claude/rules/java/hooks.md new file mode 100644 index 00000000..9dd33b38 --- /dev/null +++ b/.claude/rules/java/hooks.md @@ -0,0 +1,18 @@ +--- +paths: + - "**/*.java" + - "**/pom.xml" + - "**/build.gradle" + - "**/build.gradle.kts" +--- +# Java Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Java-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **google-java-format**: Auto-format `.java` files after edit +- **checkstyle**: Run style checks after editing Java files +- **./mvnw compile** or **./gradlew compileJava**: Verify compilation after changes diff --git a/.claude/rules/java/patterns.md b/.claude/rules/java/patterns.md new file mode 100644 index 00000000..570282bd --- /dev/null +++ b/.claude/rules/java/patterns.md @@ -0,0 +1,146 @@ +--- +paths: + - "**/*.java" +--- +# Java Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Java-specific content. + +## Repository Pattern + +Encapsulate data access behind an interface: + +```java +public interface OrderRepository { + Optional findById(Long id); + List findAll(); + Order save(Order order); + void deleteById(Long id); +} +``` + +Concrete implementations handle storage details (JPA, JDBC, in-memory for tests). + +## Service Layer + +Business logic in service classes; keep controllers and repositories thin: + +```java +public class OrderService { + private final OrderRepository orderRepository; + private final PaymentGateway paymentGateway; + + public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) { + this.orderRepository = orderRepository; + this.paymentGateway = paymentGateway; + } + + public OrderSummary placeOrder(CreateOrderRequest request) { + var order = Order.from(request); + paymentGateway.charge(order.total()); + var saved = orderRepository.save(order); + return OrderSummary.from(saved); + } +} +``` + +## Constructor Injection + +Always use constructor injection — never field injection: + +```java +// GOOD — constructor injection (testable, immutable) +public class NotificationService { + private final EmailSender emailSender; + + public NotificationService(EmailSender emailSender) { + this.emailSender = emailSender; + } +} + +// BAD — field injection (untestable without reflection, requires framework magic) +public class NotificationService { + @Inject // or @Autowired + private EmailSender emailSender; +} +``` + +## DTO Mapping + +Use records for DTOs. Map at service/controller boundaries: + +```java +public record OrderResponse(Long id, String customer, BigDecimal total) { + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getCustomerName(), order.getTotal()); + } +} +``` + +## Builder Pattern + +Use for objects with many optional parameters: + +```java +public class SearchCriteria { + private final String query; + private final int page; + private final int size; + private final String sortBy; + + private SearchCriteria(Builder builder) { + this.query = builder.query; + this.page = builder.page; + this.size = builder.size; + this.sortBy = builder.sortBy; + } + + public static class Builder { + private String query = ""; + private int page = 0; + private int size = 20; + private String sortBy = "id"; + + public Builder query(String query) { this.query = query; return this; } + public Builder page(int page) { this.page = page; return this; } + public Builder size(int size) { this.size = size; return this; } + public Builder sortBy(String sortBy) { this.sortBy = sortBy; return this; } + public SearchCriteria build() { return new SearchCriteria(this); } + } +} +``` + +## Sealed Types for Domain Models + +```java +public sealed interface PaymentResult permits PaymentSuccess, PaymentFailure { + record PaymentSuccess(String transactionId, BigDecimal amount) implements PaymentResult {} + record PaymentFailure(String errorCode, String message) implements PaymentResult {} +} + +// Exhaustive handling (Java 21+) +String message = switch (result) { + case PaymentSuccess s -> "Paid: " + s.transactionId(); + case PaymentFailure f -> "Failed: " + f.errorCode(); +}; +``` + +## API Response Envelope + +Consistent API responses: + +```java +public record ApiResponse(boolean success, T data, String error) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null); + } + public static ApiResponse error(String message) { + return new ApiResponse<>(false, null, message); + } +} +``` + +## References + +See skill: `springboot-patterns` for Spring Boot architecture patterns. +See skill: `jpa-patterns` for entity design and query optimization. diff --git a/.claude/rules/java/security.md b/.claude/rules/java/security.md new file mode 100644 index 00000000..31ca61b6 --- /dev/null +++ b/.claude/rules/java/security.md @@ -0,0 +1,100 @@ +--- +paths: + - "**/*.java" +--- +# Java Security + +> This file extends [common/security.md](../common/security.md) with Java-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in source code +- Use environment variables: `System.getenv("API_KEY")` +- Use a secret manager (Vault, AWS Secrets Manager) for production secrets +- Keep local config files with secrets in `.gitignore` + +```java +// BAD +private static final String API_KEY = "sk-abc123..."; + +// GOOD — environment variable +String apiKey = System.getenv("PAYMENT_API_KEY"); +Objects.requireNonNull(apiKey, "PAYMENT_API_KEY must be set"); +``` + +## SQL Injection Prevention + +- Always use parameterized queries — never concatenate user input into SQL +- Use `PreparedStatement` or your framework's parameterized query API +- Validate and sanitize any input used in native queries + +```java +// BAD — SQL injection via string concatenation +Statement stmt = conn.createStatement(); +String sql = "SELECT * FROM orders WHERE name = '" + name + "'"; +stmt.executeQuery(sql); + +// GOOD — PreparedStatement with parameterized query +PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE name = ?"); +ps.setString(1, name); + +// GOOD — JDBC template +jdbcTemplate.query("SELECT * FROM orders WHERE name = ?", mapper, name); +``` + +## Input Validation + +- Validate all user input at system boundaries before processing +- Use Bean Validation (`@NotNull`, `@NotBlank`, `@Size`) on DTOs when using a validation framework +- Sanitize file paths and user-provided strings before use +- Reject input that fails validation with clear error messages + +```java +// Validate manually in plain Java +public Order createOrder(String customerName, BigDecimal amount) { + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("Customer name is required"); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Amount must be positive"); + } + return new Order(customerName, amount); +} +``` + +## Authentication and Authorization + +- Never implement custom auth crypto — use established libraries +- Store passwords with bcrypt or Argon2, never MD5/SHA1 +- Enforce authorization checks at service boundaries +- Clear sensitive data from logs — never log passwords, tokens, or PII + +## Dependency Security + +- Run `mvn dependency:tree` or `./gradlew dependencies` to audit transitive dependencies +- Use OWASP Dependency-Check or Snyk to scan for known CVEs +- Keep dependencies updated — set up Dependabot or Renovate + +## Error Messages + +- Never expose stack traces, internal paths, or SQL errors in API responses +- Map exceptions to safe, generic client messages at handler boundaries +- Log detailed errors server-side; return generic messages to clients + +```java +// Log the detail, return a generic message +try { + return orderService.findById(id); +} catch (OrderNotFoundException ex) { + log.warn("Order not found: id={}", id); + return ApiResponse.error("Resource not found"); // generic, no internals +} catch (Exception ex) { + log.error("Unexpected error processing order id={}", id, ex); + return ApiResponse.error("Internal server error"); // never expose ex.getMessage() +} +``` + +## References + +See skill: `springboot-security` for Spring Security authentication and authorization patterns. +See skill: `security-review` for general security checklists. diff --git a/.claude/rules/java/testing.md b/.claude/rules/java/testing.md new file mode 100644 index 00000000..aa2e91f3 --- /dev/null +++ b/.claude/rules/java/testing.md @@ -0,0 +1,131 @@ +--- +paths: + - "**/*.java" +--- +# Java Testing + +> This file extends [common/testing.md](../common/testing.md) with Java-specific content. + +## Test Framework + +- **JUnit 5** (`@Test`, `@ParameterizedTest`, `@Nested`, `@DisplayName`) +- **AssertJ** for fluent assertions (`assertThat(result).isEqualTo(expected)`) +- **Mockito** for mocking dependencies +- **Testcontainers** for integration tests requiring databases or services + +## Test Organization + +``` +src/test/java/com/example/app/ + service/ # Unit tests for service layer + controller/ # Web layer / API tests + repository/ # Data access tests + integration/ # Cross-layer integration tests +``` + +Mirror the `src/main/java` package structure in `src/test/java`. + +## Unit Test Pattern + +```java +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderService(orderRepository); + } + + @Test + @DisplayName("findById returns order when exists") + void findById_existingOrder_returnsOrder() { + var order = new Order(1L, "Alice", BigDecimal.TEN); + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + var result = orderService.findById(1L); + + assertThat(result.customerName()).isEqualTo("Alice"); + verify(orderRepository).findById(1L); + } + + @Test + @DisplayName("findById throws when order not found") + void findById_missingOrder_throws() { + when(orderRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.findById(99L)) + .isInstanceOf(OrderNotFoundException.class) + .hasMessageContaining("99"); + } +} +``` + +## Parameterized Tests + +```java +@ParameterizedTest +@CsvSource({ + "100.00, 10, 90.00", + "50.00, 0, 50.00", + "200.00, 25, 150.00" +}) +@DisplayName("discount applied correctly") +void applyDiscount(BigDecimal price, int pct, BigDecimal expected) { + assertThat(PricingUtils.discount(price, pct)).isEqualByComparingTo(expected); +} +``` + +## Integration Tests + +Use Testcontainers for real database integration: + +```java +@Testcontainers +class OrderRepositoryIT { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); + + private OrderRepository repository; + + @BeforeEach + void setUp() { + var dataSource = new PGSimpleDataSource(); + dataSource.setUrl(postgres.getJdbcUrl()); + dataSource.setUser(postgres.getUsername()); + dataSource.setPassword(postgres.getPassword()); + repository = new JdbcOrderRepository(dataSource); + } + + @Test + void save_and_findById() { + var saved = repository.save(new Order(null, "Bob", BigDecimal.ONE)); + var found = repository.findById(saved.getId()); + assertThat(found).isPresent(); + } +} +``` + +For Spring Boot integration tests, see skill: `springboot-tdd`. + +## Test Naming + +Use descriptive names with `@DisplayName`: +- `methodName_scenario_expectedBehavior()` for method names +- `@DisplayName("human-readable description")` for reports + +## Coverage + +- Target 80%+ line coverage +- Use JaCoCo for coverage reporting +- Focus on service and domain logic — skip trivial getters/config classes + +## References + +See skill: `springboot-tdd` for Spring Boot TDD patterns with MockMvc and Testcontainers. +See skill: `java-coding-standards` for testing expectations. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..37c62cc9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(git:add*)", + "Bash(git:status*)" + ], + "ask": [ + "Bash(git:commit*)", + "Bash(git:push*)", + "Bash(git:reset*)", + "Bash(git:rebase*)", + "Bash(git:merge*)", + "Bash(git:cherry-pick*)" + ] + } +} diff --git a/.claude/skills/backend-patterns/SKILL.md b/.claude/skills/backend-patterns/SKILL.md new file mode 100644 index 00000000..42c0cbee --- /dev/null +++ b/.claude/skills/backend-patterns/SKILL.md @@ -0,0 +1,598 @@ +--- +name: backend-patterns +description: Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. +origin: ECC +--- + +# Backend Development Patterns + +Backend architecture patterns and best practices for scalable server-side applications. + +## When to Activate + +- Designing REST or GraphQL API endpoints +- Implementing repository, service, or controller layers +- Optimizing database queries (N+1, indexing, connection pooling) +- Adding caching (Redis, in-memory, HTTP cache headers) +- Setting up background jobs or async processing +- Structuring error handling and validation for APIs +- Building middleware (auth, logging, rate limiting) + +## API Design Patterns + +### RESTful API Structure + +```typescript +// ✅ Resource-based URLs +GET /api/markets # List resources +GET /api/markets/:id # Get single resource +POST /api/markets # Create resource +PUT /api/markets/:id # Replace resource +PATCH /api/markets/:id # Update resource +DELETE /api/markets/:id # Delete resource + +// ✅ Query parameters for filtering, sorting, pagination +GET /api/markets?status=active&sort=volume&limit=20&offset=0 +``` + +### Repository Pattern + +```typescript +// Abstract data access logic +interface MarketRepository { + findAll(filters?: MarketFilters): Promise + findById(id: string): Promise + create(data: CreateMarketDto): Promise + update(id: string, data: UpdateMarketDto): Promise + delete(id: string): Promise +} + +class SupabaseMarketRepository implements MarketRepository { + async findAll(filters?: MarketFilters): Promise { + let query = supabase.from('markets').select('*') + + if (filters?.status) { + query = query.eq('status', filters.status) + } + + if (filters?.limit) { + query = query.limit(filters.limit) + } + + const { data, error } = await query + + if (error) throw new Error(error.message) + return data + } + + // Other methods... +} +``` + +### Service Layer Pattern + +```typescript +// Business logic separated from data access +class MarketService { + constructor(private marketRepo: MarketRepository) {} + + async searchMarkets(query: string, limit: number = 10): Promise { + // Business logic + const embedding = await generateEmbedding(query) + const results = await this.vectorSearch(embedding, limit) + + // Fetch full data + const markets = await this.marketRepo.findByIds(results.map(r => r.id)) + + // Sort by similarity + return markets.sort((a, b) => { + const scoreA = results.find(r => r.id === a.id)?.score || 0 + const scoreB = results.find(r => r.id === b.id)?.score || 0 + return scoreA - scoreB + }) + } + + private async vectorSearch(embedding: number[], limit: number) { + // Vector search implementation + } +} +``` + +### Middleware Pattern + +```typescript +// Request/response processing pipeline +export function withAuth(handler: NextApiHandler): NextApiHandler { + return async (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', '') + + if (!token) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + try { + const user = await verifyToken(token) + req.user = user + return handler(req, res) + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }) + } + } +} + +// Usage +export default withAuth(async (req, res) => { + // Handler has access to req.user +}) +``` + +## Database Patterns + +### Query Optimization + +```typescript +// ✅ GOOD: Select only needed columns +const { data } = await supabase + .from('markets') + .select('id, name, status, volume') + .eq('status', 'active') + .order('volume', { ascending: false }) + .limit(10) + +// ❌ BAD: Select everything +const { data } = await supabase + .from('markets') + .select('*') +``` + +### N+1 Query Prevention + +```typescript +// ❌ BAD: N+1 query problem +const markets = await getMarkets() +for (const market of markets) { + market.creator = await getUser(market.creator_id) // N queries +} + +// ✅ GOOD: Batch fetch +const markets = await getMarkets() +const creatorIds = markets.map(m => m.creator_id) +const creators = await getUsers(creatorIds) // 1 query +const creatorMap = new Map(creators.map(c => [c.id, c])) + +markets.forEach(market => { + market.creator = creatorMap.get(market.creator_id) +}) +``` + +### Transaction Pattern + +```typescript +async function createMarketWithPosition( + marketData: CreateMarketDto, + positionData: CreatePositionDto +) { + // Use Supabase transaction + const { data, error } = await supabase.rpc('create_market_with_position', { + market_data: marketData, + position_data: positionData + }) + + if (error) throw new Error('Transaction failed') + return data +} + +// SQL function in Supabase +CREATE OR REPLACE FUNCTION create_market_with_position( + market_data jsonb, + position_data jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +BEGIN + -- Start transaction automatically + INSERT INTO markets VALUES (market_data); + INSERT INTO positions VALUES (position_data); + RETURN jsonb_build_object('success', true); +EXCEPTION + WHEN OTHERS THEN + -- Rollback happens automatically + RETURN jsonb_build_object('success', false, 'error', SQLERRM); +END; +$$; +``` + +## Caching Strategies + +### Redis Caching Layer + +```typescript +class CachedMarketRepository implements MarketRepository { + constructor( + private baseRepo: MarketRepository, + private redis: RedisClient + ) {} + + async findById(id: string): Promise { + // Check cache first + const cached = await this.redis.get(`market:${id}`) + + if (cached) { + return JSON.parse(cached) + } + + // Cache miss - fetch from database + const market = await this.baseRepo.findById(id) + + if (market) { + // Cache for 5 minutes + await this.redis.setex(`market:${id}`, 300, JSON.stringify(market)) + } + + return market + } + + async invalidateCache(id: string): Promise { + await this.redis.del(`market:${id}`) + } +} +``` + +### Cache-Aside Pattern + +```typescript +async function getMarketWithCache(id: string): Promise { + const cacheKey = `market:${id}` + + // Try cache + const cached = await redis.get(cacheKey) + if (cached) return JSON.parse(cached) + + // Cache miss - fetch from DB + const market = await db.markets.findUnique({ where: { id } }) + + if (!market) throw new Error('Market not found') + + // Update cache + await redis.setex(cacheKey, 300, JSON.stringify(market)) + + return market +} +``` + +## Error Handling Patterns + +### Centralized Error Handler + +```typescript +class ApiError extends Error { + constructor( + public statusCode: number, + public message: string, + public isOperational = true + ) { + super(message) + Object.setPrototypeOf(this, ApiError.prototype) + } +} + +export function errorHandler(error: unknown, req: Request): Response { + if (error instanceof ApiError) { + return NextResponse.json({ + success: false, + error: error.message + }, { status: error.statusCode }) + } + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation failed', + details: error.errors + }, { status: 400 }) + } + + // Log unexpected errors + console.error('Unexpected error:', error) + + return NextResponse.json({ + success: false, + error: 'Internal server error' + }, { status: 500 }) +} + +// Usage +export async function GET(request: Request) { + try { + const data = await fetchData() + return NextResponse.json({ success: true, data }) + } catch (error) { + return errorHandler(error, request) + } +} +``` + +### Retry with Exponential Backoff + +```typescript +async function fetchWithRetry( + fn: () => Promise, + maxRetries = 3 +): Promise { + let lastError: Error + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error as Error + + if (i < maxRetries - 1) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, i) * 1000 + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + } + + throw lastError! +} + +// Usage +const data = await fetchWithRetry(() => fetchFromAPI()) +``` + +## Authentication & Authorization + +### JWT Token Validation + +```typescript +import jwt from 'jsonwebtoken' + +interface JWTPayload { + userId: string + email: string + role: 'admin' | 'user' +} + +export function verifyToken(token: string): JWTPayload { + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload + return payload + } catch (error) { + throw new ApiError(401, 'Invalid token') + } +} + +export async function requireAuth(request: Request) { + const token = request.headers.get('authorization')?.replace('Bearer ', '') + + if (!token) { + throw new ApiError(401, 'Missing authorization token') + } + + return verifyToken(token) +} + +// Usage in API route +export async function GET(request: Request) { + const user = await requireAuth(request) + + const data = await getDataForUser(user.userId) + + return NextResponse.json({ success: true, data }) +} +``` + +### Role-Based Access Control + +```typescript +type Permission = 'read' | 'write' | 'delete' | 'admin' + +interface User { + id: string + role: 'admin' | 'moderator' | 'user' +} + +const rolePermissions: Record = { + admin: ['read', 'write', 'delete', 'admin'], + moderator: ['read', 'write', 'delete'], + user: ['read', 'write'] +} + +export function hasPermission(user: User, permission: Permission): boolean { + return rolePermissions[user.role].includes(permission) +} + +export function requirePermission(permission: Permission) { + return (handler: (request: Request, user: User) => Promise) => { + return async (request: Request) => { + const user = await requireAuth(request) + + if (!hasPermission(user, permission)) { + throw new ApiError(403, 'Insufficient permissions') + } + + return handler(request, user) + } + } +} + +// Usage - HOF wraps the handler +export const DELETE = requirePermission('delete')( + async (request: Request, user: User) => { + // Handler receives authenticated user with verified permission + return new Response('Deleted', { status: 200 }) + } +) +``` + +## Rate Limiting + +### Simple In-Memory Rate Limiter + +```typescript +class RateLimiter { + private requests = new Map() + + async checkLimit( + identifier: string, + maxRequests: number, + windowMs: number + ): Promise { + const now = Date.now() + const requests = this.requests.get(identifier) || [] + + // Remove old requests outside window + const recentRequests = requests.filter(time => now - time < windowMs) + + if (recentRequests.length >= maxRequests) { + return false // Rate limit exceeded + } + + // Add current request + recentRequests.push(now) + this.requests.set(identifier, recentRequests) + + return true + } +} + +const limiter = new RateLimiter() + +export async function GET(request: Request) { + const ip = request.headers.get('x-forwarded-for') || 'unknown' + + const allowed = await limiter.checkLimit(ip, 100, 60000) // 100 req/min + + if (!allowed) { + return NextResponse.json({ + error: 'Rate limit exceeded' + }, { status: 429 }) + } + + // Continue with request +} +``` + +## Background Jobs & Queues + +### Simple Queue Pattern + +```typescript +class JobQueue { + private queue: T[] = [] + private processing = false + + async add(job: T): Promise { + this.queue.push(job) + + if (!this.processing) { + this.process() + } + } + + private async process(): Promise { + this.processing = true + + while (this.queue.length > 0) { + const job = this.queue.shift()! + + try { + await this.execute(job) + } catch (error) { + console.error('Job failed:', error) + } + } + + this.processing = false + } + + private async execute(job: T): Promise { + // Job execution logic + } +} + +// Usage for indexing markets +interface IndexJob { + marketId: string +} + +const indexQueue = new JobQueue() + +export async function POST(request: Request) { + const { marketId } = await request.json() + + // Add to queue instead of blocking + await indexQueue.add({ marketId }) + + return NextResponse.json({ success: true, message: 'Job queued' }) +} +``` + +## Logging & Monitoring + +### Structured Logging + +```typescript +interface LogContext { + userId?: string + requestId?: string + method?: string + path?: string + [key: string]: unknown +} + +class Logger { + log(level: 'info' | 'warn' | 'error', message: string, context?: LogContext) { + const entry = { + timestamp: new Date().toISOString(), + level, + message, + ...context + } + + console.log(JSON.stringify(entry)) + } + + info(message: string, context?: LogContext) { + this.log('info', message, context) + } + + warn(message: string, context?: LogContext) { + this.log('warn', message, context) + } + + error(message: string, error: Error, context?: LogContext) { + this.log('error', message, { + ...context, + error: error.message, + stack: error.stack + }) + } +} + +const logger = new Logger() + +// Usage +export async function GET(request: Request) { + const requestId = crypto.randomUUID() + + logger.info('Fetching markets', { + requestId, + method: 'GET', + path: '/api/markets' + }) + + try { + const markets = await fetchMarkets() + return NextResponse.json({ success: true, data: markets }) + } catch (error) { + logger.error('Failed to fetch markets', error as Error, { requestId }) + return NextResponse.json({ error: 'Internal error' }, { status: 500 }) + } +} +``` + +**Remember**: Backend patterns enable scalable, maintainable server-side applications. Choose patterns that fit your complexity level. diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md new file mode 100644 index 00000000..7eeadd96 --- /dev/null +++ b/.claude/skills/debug/SKILL.md @@ -0,0 +1,6 @@ +## Debugging Workflow +1. Read the error/logs the user provides — trust they are current +2. Analyze the root cause BEFORE exploring the codebase +3. Propose a fix targeting ONLY the specific file/component mentioned +4. After fixing, run the specific failing test, not the full suite +5. Do NOT commit changes — just report results diff --git a/.claude/skills/fix-java/SKILL.md b/.claude/skills/fix-java/SKILL.md new file mode 100644 index 00000000..fc804a11 --- /dev/null +++ b/.claude/skills/fix-java/SKILL.md @@ -0,0 +1,18 @@ +## Java Bug Fix Workflow + +Replace `` and `` with the actual class name and Maven module before starting. + +### Rules +1. Do NOT touch any other classes besides `` +2. Do NOT move, rename, or delete any test files +3. Do NOT make any git commits +4. After each edit, run `./mvnw compile -pl ` — do NOT proceed until it passes +5. Run only the specific failing test, not the full suite + +### Steps +1. Read `` and understand the current logic +2. Write a **failing** unit test that demonstrates the bug — show it to the user and wait for approval +3. After approval, fix only `` +4. Run `./mvnw test -pl -Dtest=` and show results +5. Repeat steps 3-4 until the test passes +6. Report results — do NOT commit diff --git a/.claude/skills/java-coding-standards/SKILL.md b/.claude/skills/java-coding-standards/SKILL.md new file mode 100644 index 00000000..af990255 --- /dev/null +++ b/.claude/skills/java-coding-standards/SKILL.md @@ -0,0 +1,147 @@ +--- +name: java-coding-standards +description: "Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout." +origin: ECC +--- + +# Java Coding Standards + +Standards for readable, maintainable Java (17+) code in Spring Boot services. + +## When to Activate + +- Writing or reviewing Java code in Spring Boot projects +- Enforcing naming, immutability, or exception handling conventions +- Working with records, sealed classes, or pattern matching (Java 17+) +- Reviewing use of Optional, streams, or generics +- Structuring packages and project layout + +## Core Principles + +- Prefer clarity over cleverness +- Immutable by default; minimize shared mutable state +- Fail fast with meaningful exceptions +- Consistent naming and package structure + +## Naming + +```java +// ✅ Classes/Records: PascalCase +public class MarketService {} +public record Money(BigDecimal amount, Currency currency) {} + +// ✅ Methods/fields: camelCase +private final MarketRepository marketRepository; +public Market findBySlug(String slug) {} + +// ✅ Constants: UPPER_SNAKE_CASE +private static final int MAX_PAGE_SIZE = 100; +``` + +## Immutability + +```java +// ✅ Favor records and final fields +public record MarketDto(Long id, String name, MarketStatus status) {} + +public class Market { + private final Long id; + private final String name; + // getters only, no setters +} +``` + +## Optional Usage + +```java +// ✅ Return Optional from find* methods +Optional market = marketRepository.findBySlug(slug); + +// ✅ Map/flatMap instead of get() +return market + .map(MarketResponse::from) + .orElseThrow(() -> new EntityNotFoundException("Market not found")); +``` + +## Streams Best Practices + +```java +// ✅ Use streams for transformations, keep pipelines short +List names = markets.stream() + .map(Market::name) + .filter(Objects::nonNull) + .toList(); + +// ❌ Avoid complex nested streams; prefer loops for clarity +``` + +## Exceptions + +- Use unchecked exceptions for domain errors; wrap technical exceptions with context +- Create domain-specific exceptions (e.g., `MarketNotFoundException`) +- Avoid broad `catch (Exception ex)` unless rethrowing/logging centrally + +```java +throw new MarketNotFoundException(slug); +``` + +## Generics and Type Safety + +- Avoid raw types; declare generic parameters +- Prefer bounded generics for reusable utilities + +```java +public Map indexById(Collection items) { ... } +``` + +## Project Structure (Maven/Gradle) + +``` +src/main/java/com/example/app/ + config/ + controller/ + service/ + repository/ + domain/ + dto/ + util/ +src/main/resources/ + application.yml +src/test/java/... (mirrors main) +``` + +## Formatting and Style + +- Use 2 or 4 spaces consistently (project standard) +- One public top-level type per file +- Keep methods short and focused; extract helpers +- Order members: constants, fields, constructors, public methods, protected, private + +## Code Smells to Avoid + +- Long parameter lists → use DTO/builders +- Deep nesting → early returns +- Magic numbers → named constants +- Static mutable state → prefer dependency injection +- Silent catch blocks → log and act or rethrow + +## Logging + +```java +private static final Logger log = LoggerFactory.getLogger(MarketService.class); +log.info("fetch_market slug={}", slug); +log.error("failed_fetch_market slug={}", slug, ex); +``` + +## Null Handling + +- Accept `@Nullable` only when unavoidable; otherwise use `@NonNull` +- Use Bean Validation (`@NotNull`, `@NotBlank`) on inputs + +## Testing Expectations + +- JUnit 5 + AssertJ for fluent assertions +- Mockito for mocking; avoid partial mocks where possible +- Favor deterministic tests; no hidden sleeps + +**Remember**: Keep code intentional, typed, and observable. Optimize for maintainability over micro-optimizations unless proven necessary. diff --git a/.claude/skills/springboot-patterns/SKILL.md b/.claude/skills/springboot-patterns/SKILL.md new file mode 100644 index 00000000..6627ec6d --- /dev/null +++ b/.claude/skills/springboot-patterns/SKILL.md @@ -0,0 +1,314 @@ +--- +name: springboot-patterns +description: Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. +origin: ECC +--- + +# Spring Boot Development Patterns + +Spring Boot architecture and API patterns for scalable, production-grade services. + +## When to Activate + +- Building REST APIs with Spring MVC or WebFlux +- Structuring controller → service → repository layers +- Configuring Spring Data JPA, caching, or async processing +- Adding validation, exception handling, or pagination +- Setting up profiles for dev/staging/production environments +- Implementing event-driven patterns with Spring Events or Kafka + +## REST API Structure + +```java +@RestController +@RequestMapping("/api/markets") +@Validated +class MarketController { + private final MarketService marketService; + + MarketController(MarketService marketService) { + this.marketService = marketService; + } + + @GetMapping + ResponseEntity> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page markets = marketService.list(PageRequest.of(page, size)); + return ResponseEntity.ok(markets.map(MarketResponse::from)); + } + + @PostMapping + ResponseEntity create(@Valid @RequestBody CreateMarketRequest request) { + Market market = marketService.create(request); + return ResponseEntity.status(HttpStatus.CREATED).body(MarketResponse.from(market)); + } +} +``` + +## Repository Pattern (Spring Data JPA) + +```java +public interface MarketRepository extends JpaRepository { + @Query("select m from MarketEntity m where m.status = :status order by m.volume desc") + List findActive(@Param("status") MarketStatus status, Pageable pageable); +} +``` + +## Service Layer with Transactions + +```java +@Service +public class MarketService { + private final MarketRepository repo; + + public MarketService(MarketRepository repo) { + this.repo = repo; + } + + @Transactional + public Market create(CreateMarketRequest request) { + MarketEntity entity = MarketEntity.from(request); + MarketEntity saved = repo.save(entity); + return Market.from(saved); + } +} +``` + +## DTOs and Validation + +```java +public record CreateMarketRequest( + @NotBlank @Size(max = 200) String name, + @NotBlank @Size(max = 2000) String description, + @NotNull @FutureOrPresent Instant endDate, + @NotEmpty List<@NotBlank String> categories) {} + +public record MarketResponse(Long id, String name, MarketStatus status) { + static MarketResponse from(Market market) { + return new MarketResponse(market.id(), market.name(), market.status()); + } +} +``` + +## Exception Handling + +```java +@ControllerAdvice +class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) + ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest().body(ApiError.validation(message)); + } + + @ExceptionHandler(AccessDeniedException.class) + ResponseEntity handleAccessDenied() { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiError.of("Forbidden")); + } + + @ExceptionHandler(Exception.class) + ResponseEntity handleGeneric(Exception ex) { + // Log unexpected errors with stack traces + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiError.of("Internal server error")); + } +} +``` + +## Caching + +Requires `@EnableCaching` on a configuration class. + +```java +@Service +public class MarketCacheService { + private final MarketRepository repo; + + public MarketCacheService(MarketRepository repo) { + this.repo = repo; + } + + @Cacheable(value = "market", key = "#id") + public Market getById(Long id) { + return repo.findById(id) + .map(Market::from) + .orElseThrow(() -> new EntityNotFoundException("Market not found")); + } + + @CacheEvict(value = "market", key = "#id") + public void evict(Long id) {} +} +``` + +## Async Processing + +Requires `@EnableAsync` on a configuration class. + +```java +@Service +public class NotificationService { + @Async + public CompletableFuture sendAsync(Notification notification) { + // send email/SMS + return CompletableFuture.completedFuture(null); + } +} +``` + +## Logging (SLF4J) + +```java +@Service +public class ReportService { + private static final Logger log = LoggerFactory.getLogger(ReportService.class); + + public Report generate(Long marketId) { + log.info("generate_report marketId={}", marketId); + try { + // logic + } catch (Exception ex) { + log.error("generate_report_failed marketId={}", marketId, ex); + throw ex; + } + return new Report(); + } +} +``` + +## Middleware / Filters + +```java +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + try { + filterChain.doFilter(request, response); + } finally { + long duration = System.currentTimeMillis() - start; + log.info("req method={} uri={} status={} durationMs={}", + request.getMethod(), request.getRequestURI(), response.getStatus(), duration); + } + } +} +``` + +## Pagination and Sorting + +```java +PageRequest page = PageRequest.of(pageNumber, pageSize, Sort.by("createdAt").descending()); +Page results = marketService.list(page); +``` + +## Error-Resilient External Calls + +```java +public T withRetry(Supplier supplier, int maxRetries) { + int attempts = 0; + while (true) { + try { + return supplier.get(); + } catch (Exception ex) { + attempts++; + if (attempts >= maxRetries) { + throw ex; + } + try { + Thread.sleep((long) Math.pow(2, attempts) * 100L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ex; + } + } + } +} +``` + +## Rate Limiting (Filter + Bucket4j) + +**Security Note**: The `X-Forwarded-For` header is untrusted by default because clients can spoof it. +Only use forwarded headers when: +1. Your app is behind a trusted reverse proxy (nginx, AWS ALB, etc.) +2. You have registered `ForwardedHeaderFilter` as a bean +3. You have configured `server.forward-headers-strategy=NATIVE` or `FRAMEWORK` in application properties +4. Your proxy is configured to overwrite (not append to) the `X-Forwarded-For` header + +When `ForwardedHeaderFilter` is properly configured, `request.getRemoteAddr()` will automatically +return the correct client IP from the forwarded headers. Without this configuration, use +`request.getRemoteAddr()` directly—it returns the immediate connection IP, which is the only +trustworthy value. + +```java +@Component +public class RateLimitFilter extends OncePerRequestFilter { + private final Map buckets = new ConcurrentHashMap<>(); + + /* + * SECURITY: This filter uses request.getRemoteAddr() to identify clients for rate limiting. + * + * If your application is behind a reverse proxy (nginx, AWS ALB, etc.), you MUST configure + * Spring to handle forwarded headers properly for accurate client IP detection: + * + * 1. Set server.forward-headers-strategy=NATIVE (for cloud platforms) or FRAMEWORK in + * application.properties/yaml + * 2. If using FRAMEWORK strategy, register ForwardedHeaderFilter: + * + * @Bean + * ForwardedHeaderFilter forwardedHeaderFilter() { + * return new ForwardedHeaderFilter(); + * } + * + * 3. Ensure your proxy overwrites (not appends) the X-Forwarded-For header to prevent spoofing + * 4. Configure server.tomcat.remoteip.trusted-proxies or equivalent for your container + * + * Without this configuration, request.getRemoteAddr() returns the proxy IP, not the client IP. + * Do NOT read X-Forwarded-For directly—it is trivially spoofable without trusted proxy handling. + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // Use getRemoteAddr() which returns the correct client IP when ForwardedHeaderFilter + // is configured, or the direct connection IP otherwise. Never trust X-Forwarded-For + // headers directly without proper proxy configuration. + String clientIp = request.getRemoteAddr(); + + Bucket bucket = buckets.computeIfAbsent(clientIp, + k -> Bucket.builder() + .addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1)))) + .build()); + + if (bucket.tryConsume(1)) { + filterChain.doFilter(request, response); + } else { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + } + } +} +``` + +## Background Jobs + +Use Spring’s `@Scheduled` or integrate with queues (e.g., Kafka, SQS, RabbitMQ). Keep handlers idempotent and observable. + +## Observability + +- Structured logging (JSON) via Logback encoder +- Metrics: Micrometer + Prometheus/OTel +- Tracing: Micrometer Tracing with OpenTelemetry or Brave backend + +## Production Defaults + +- Prefer constructor injection, avoid field injection +- Enable `spring.mvc.problemdetails.enabled=true` for RFC 7807 errors (Spring Boot 3+) +- Configure HikariCP pool sizes for workload, set timeouts +- Use `@Transactional(readOnly = true)` for queries +- Enforce null-safety via `@NonNull` and `Optional` where appropriate + +**Remember**: Keep controllers thin, services focused, repositories simple, and errors handled centrally. Optimize for maintainability and testability. diff --git a/.claude/skills/springboot-tdd/SKILL.md b/.claude/skills/springboot-tdd/SKILL.md new file mode 100644 index 00000000..246afbdf --- /dev/null +++ b/.claude/skills/springboot-tdd/SKILL.md @@ -0,0 +1,158 @@ +--- +name: springboot-tdd +description: Test-driven development for Spring Boot using JUnit 5, Mockito, MockMvc, Testcontainers, and JaCoCo. Use when adding features, fixing bugs, or refactoring. +origin: ECC +--- + +# Spring Boot TDD Workflow + +TDD guidance for Spring Boot services with 80%+ coverage (unit + integration). + +## When to Use + +- New features or endpoints +- Bug fixes or refactors +- Adding data access logic or security rules + +## Workflow + +1) Write tests first (they should fail) +2) Implement minimal code to pass +3) Refactor with tests green +4) Enforce coverage (JaCoCo) + +## Unit Tests (JUnit 5 + Mockito) + +```java +@ExtendWith(MockitoExtension.class) +class MarketServiceTest { + @Mock MarketRepository repo; + @InjectMocks MarketService service; + + @Test + void createsMarket() { + CreateMarketRequest req = new CreateMarketRequest("name", "desc", Instant.now(), List.of("cat")); + when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Market result = service.create(req); + + assertThat(result.name()).isEqualTo("name"); + verify(repo).save(any()); + } +} +``` + +Patterns: +- Arrange-Act-Assert +- Avoid partial mocks; prefer explicit stubbing +- Use `@ParameterizedTest` for variants + +## Web Layer Tests (MockMvc) + +```java +@WebMvcTest(MarketController.class) +class MarketControllerTest { + @Autowired MockMvc mockMvc; + @MockBean MarketService marketService; + + @Test + void returnsMarkets() throws Exception { + when(marketService.list(any())).thenReturn(Page.empty()); + + mockMvc.perform(get("/api/markets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } +} +``` + +## Integration Tests (SpringBootTest) + +```java +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MarketIntegrationTest { + @Autowired MockMvc mockMvc; + + @Test + void createsMarket() throws Exception { + mockMvc.perform(post("/api/markets") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"Test","description":"Desc","endDate":"2030-01-01T00:00:00Z","categories":["general"]} + """)) + .andExpect(status().isCreated()); + } +} +``` + +## Persistence Tests (DataJpaTest) + +```java +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestContainersConfig.class) +class MarketRepositoryTest { + @Autowired MarketRepository repo; + + @Test + void savesAndFinds() { + MarketEntity entity = new MarketEntity(); + entity.setName("Test"); + repo.save(entity); + + Optional found = repo.findByName("Test"); + assertThat(found).isPresent(); + } +} +``` + +## Testcontainers + +- Use reusable containers for Postgres/Redis to mirror production +- Wire via `@DynamicPropertySource` to inject JDBC URLs into Spring context + +## Coverage (JaCoCo) + +Maven snippet: +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + prepare-agent + + + report + verify + report + + + +``` + +## Assertions + +- Prefer AssertJ (`assertThat`) for readability +- For JSON responses, use `jsonPath` +- For exceptions: `assertThatThrownBy(...)` + +## Test Data Builders + +```java +class MarketBuilder { + private String name = "Test"; + MarketBuilder withName(String name) { this.name = name; return this; } + Market build() { return new Market(null, name, MarketStatus.ACTIVE); } +} +``` + +## CI Commands + +- Maven: `mvn -T 4 test` or `mvn verify` +- Gradle: `./gradlew test jacocoTestReport` + +**Remember**: Keep tests fast, isolated, and deterministic. Test behavior, not implementation details. diff --git a/.claude/skills/springboot-verification/SKILL.md b/.claude/skills/springboot-verification/SKILL.md new file mode 100644 index 00000000..c8f790aa --- /dev/null +++ b/.claude/skills/springboot-verification/SKILL.md @@ -0,0 +1,231 @@ +--- +name: springboot-verification +description: "Verification loop for Spring Boot projects: build, static analysis, tests with coverage, security scans, and diff review before release or PR." +origin: ECC +--- + +# Spring Boot Verification Loop + +Run before PRs, after major changes, and pre-deploy. + +## When to Activate + +- Before opening a pull request for a Spring Boot service +- After major refactoring or dependency upgrades +- Pre-deployment verification for staging or production +- Running full build → lint → test → security scan pipeline +- Validating test coverage meets thresholds + +## Phase 1: Build + +```bash +mvn -T 4 clean verify -DskipTests +# or +./gradlew clean assemble -x test +``` + +If build fails, stop and fix. + +## Phase 2: Static Analysis + +Maven (common plugins): +```bash +mvn -T 4 spotbugs:check pmd:check checkstyle:check +``` + +Gradle (if configured): +```bash +./gradlew checkstyleMain pmdMain spotbugsMain +``` + +## Phase 3: Tests + Coverage + +```bash +mvn -T 4 test +mvn jacoco:report # verify 80%+ coverage +# or +./gradlew test jacocoTestReport +``` + +Report: +- Total tests, passed/failed +- Coverage % (lines/branches) + +### Unit Tests + +Test service logic in isolation with mocked dependencies: + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock private UserRepository userRepository; + @InjectMocks private UserService userService; + + @Test + void createUser_validInput_returnsUser() { + var dto = new CreateUserDto("Alice", "alice@example.com"); + var expected = new User(1L, "Alice", "alice@example.com"); + when(userRepository.save(any(User.class))).thenReturn(expected); + + var result = userService.create(dto); + + assertThat(result.name()).isEqualTo("Alice"); + verify(userRepository).save(any(User.class)); + } + + @Test + void createUser_duplicateEmail_throwsException() { + var dto = new CreateUserDto("Alice", "existing@example.com"); + when(userRepository.existsByEmail(dto.email())).thenReturn(true); + + assertThatThrownBy(() -> userService.create(dto)) + .isInstanceOf(DuplicateEmailException.class); + } +} +``` + +### Integration Tests with Testcontainers + +Test against a real database instead of H2: + +```java +@SpringBootTest +@Testcontainers +class UserRepositoryIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("testdb"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired private UserRepository userRepository; + + @Test + void findByEmail_existingUser_returnsUser() { + userRepository.save(new User("Alice", "alice@example.com")); + + var found = userRepository.findByEmail("alice@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Alice"); + } +} +``` + +### API Tests with MockMvc + +Test controller layer with full Spring context: + +```java +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private UserService userService; + + @Test + void createUser_validInput_returns201() throws Exception { + var user = new UserDto(1L, "Alice", "alice@example.com"); + when(userService.create(any())).thenReturn(user); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": "Alice", "email": "alice@example.com"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Alice")); + } + + @Test + void createUser_invalidEmail_returns400() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": "Alice", "email": "not-an-email"} + """)) + .andExpect(status().isBadRequest()); + } +} +``` + +## Phase 4: Security Scan + +```bash +# Dependency CVEs +mvn org.owasp:dependency-check-maven:check +# or +./gradlew dependencyCheckAnalyze + +# Secrets in source +grep -rn "password\s*=\s*\"" src/ --include="*.java" --include="*.yml" --include="*.properties" +grep -rn "sk-\|api_key\|secret" src/ --include="*.java" --include="*.yml" + +# Secrets (git history) +git secrets --scan # if configured +``` + +### Common Security Findings + +``` +# Check for System.out.println (use logger instead) +grep -rn "System\.out\.print" src/main/ --include="*.java" + +# Check for raw exception messages in responses +grep -rn "e\.getMessage()" src/main/ --include="*.java" + +# Check for wildcard CORS +grep -rn "allowedOrigins.*\*" src/main/ --include="*.java" +``` + +## Phase 5: Lint/Format (optional gate) + +```bash +mvn spotless:apply # if using Spotless plugin +./gradlew spotlessApply +``` + +## Phase 6: Diff Review + +```bash +git diff --stat +git diff +``` + +Checklist: +- No debugging logs left (`System.out`, `log.debug` without guards) +- Meaningful errors and HTTP statuses +- Transactions and validation present where needed +- Config changes documented + +## Output Template + +``` +VERIFICATION REPORT +=================== +Build: [PASS/FAIL] +Static: [PASS/FAIL] (spotbugs/pmd/checkstyle) +Tests: [PASS/FAIL] (X/Y passed, Z% coverage) +Security: [PASS/FAIL] (CVE findings: N) +Diff: [X files changed] + +Overall: [READY / NOT READY] + +Issues to Fix: +1. ... +2. ... +``` + +## Continuous Mode + +- Re-run phases on significant changes or every 30–60 minutes in long sessions +- Keep a short loop: `mvn -T 4 test` + spotbugs for quick feedback + +**Remember**: Fast feedback beats late surprises. Keep the gate strict—treat warnings as defects in production systems. diff --git a/.claudignore b/.claudignore new file mode 100644 index 00000000..fed3ac0d --- /dev/null +++ b/.claudignore @@ -0,0 +1,56 @@ +# Build outputs +/target/ +/build/ +*.class + +# IDE +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.eclipse/ +.settings/ +.classpath +.project + +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# OS +.DS_Store +Thumbs.db +.env +.env.local + +# Maven +.m2/ +dependency-reduced-pom.xml + +# Gradle (if used) +.gradle/ +gradle/ + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.swp +*.swo +*~ +.#* + +# Generated files +/dist/ +/out/ +*.jar +*.war + +# Node/NPM (if used) +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..1121b533 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +scripts/check-commit-guards.sh diff --git a/.gitignore b/.gitignore index affacbfe..c7165ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,8 @@ .DS_Store **/.DS_Store /.env -/.cursor/mcp.json /target/ -/.claude/worktrees/flamboyant-heyrovsky/ -/.claude/ +/.claude/worktrees/* /scripts/ollama-check-vision.ps1 /opendaimon.code-workspace @@ -41,3 +39,7 @@ /cli/prometheus.yml /cli/node_modules/ /cli/*.tgz +/cli/application-simple.yml +/cli/application-simple.yml.example +/logs/ +/.mcp.json diff --git a/.run/Application.run.xml b/.run/Application.run.xml index a8e7bf7e..fd9f4217 100644 --- a/.run/Application.run.xml +++ b/.run/Application.run.xml @@ -13,4 +13,17 @@