Skip to content

feat: per-company invoice classifier configuration#170

Merged
emilwojtaszek merged 6 commits into
mainfrom
feat/per-company-classifier-config
Apr 23, 2026
Merged

feat: per-company invoice classifier configuration#170
emilwojtaszek merged 6 commits into
mainfrom
feat/per-company-classifier-config

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Apr 23, 2026

Copy link
Copy Markdown
Member

Summary

  • Add Settings → Services page for per-company invoice classifier configuration
  • Company owners can override global env-var defaults with custom classifier URL, API token, and confidence thresholds — enabling dedicated ML models per company
  • Override is opt-in (disabled by default) — env vars drive everything until explicitly enabled, preserving local dev experience when connecting to shared databases

Key design decisions (ADR-0049)

  • Env vars remain the baselineruntime.exs config is untouched; DB overrides only apply when a company explicitly enables them
  • Classifier-only — PDF renderer and invoice extractor use global env vars exclusively (99.9% of users never change them)
  • Company-scoped — one company's config change can't affect another
  • Conditional validation — URL and thresholds are required only when override is enabled

New files

File Purpose
lib/ksef_hub/service_config.ex Context — CRUD, env_defaults
lib/ksef_hub/service_config/classifier_config.ex Schema with conditional validation
lib/ksef_hub_web/live/settings_live/services.ex LiveView — settings page
docs/adr/0049-per-company-classifier-config.md Architecture decision record

Test plan

  • Context tests: CRUD, token encryption, conditional validation, company isolation
  • LiveView tests: access control (owner/admin only), rendering, form validation, company data isolation
  • Existing sidecar client tests pass (env var isolation setup added)
  • Full suite: 2127 tests, 0 failures (5 consecutive runs)
  • Manual: visit /c/:id/settings/services, toggle override, save with health check

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Services settings page to manage per-company invoice classifier: enable/disable, URL, API token, category/tag confidence thresholds
    • Built-in health check, async probe behavior, and "Save anyway" confirmation; API endpoints docs toggle
  • Permissions

    • Services tab gated to users with manage-services permission
  • Documentation

    • ADR and docs updated to describe per-company overrides with environment-variable fallbacks
  • Tests

    • New unit and LiveView tests covering UI, health checks, validation, persistence, and company isolation

Add a Settings → Services page where company owners can override the
global env-var classifier config with per-company values (URL, API token,
confidence thresholds). This enables dedicated ML models per company
while preserving env-var defaults for local development.

- New `classifier_configs` table (company-scoped, unique per company)
- `ServiceConfig` context with CRUD and env_defaults helper
- `ClassifierConfig` schema with conditional validation (required fields
  only when override is enabled)
- LiveView with health check before save, collapsible API docs, and
  env-var placeholders showing current defaults
- Owner/admin-only access via `:manage_services` permission
- ADR-0049 documents the design decisions and rejected alternatives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds DB-backed per-company invoice classifier configuration with encrypted API token, env-var fallbacks, a permission-gated Services settings LiveView at /c/:company_id/settings/services including async health probes and confirmation flow; runtime clients remain reading global env vars for now.

Changes

Cohort / File(s) Summary
Documentation
docs/adr/0049-per-company-classifier-config.md, docs/architecture.md, docs/infrastructure.md, docs/sidecar-services.md
New ADR and docs updates describing per-company classifier config, env-var fallback behavior, settings UI location, schema, and UX/health-check details.
Auth & UI wiring
lib/ksef_hub/authorization.ex, lib/ksef_hub_web/components/settings_components.ex, lib/ksef_hub_web/router.ex
Adds :manage_services permission, inserts a Services tab (permission-gated) and mounts Services LiveView route under company settings.
Service Config Core
lib/ksef_hub/service_config.ex, lib/ksef_hub/service_config/classifier_config.ex
New KsefHub.ServiceConfig context and ClassifierConfig Ecto schema; CRUD, changeset validations (enabled gating, URL format, thresholds in (0,1)), token encryption/clearing behavior, and env_defaults/0.
Web UI / LiveView
lib/ksef_hub_web/live/settings_live/services.ex
New LiveView implementing mount, form, async health probes, pre-save probe + confirmation flow, host/IP allowlisting, and persistence via ServiceConfig.
Database migrations
priv/repo/migrations/...create_service_configurations.exs, ...update_service_configurations_default_disabled.exs, ...replace_service_configurations_with_classifier_configs.exs
Creates initial service_configurations, disables them, then migrates to company-scoped classifier_configs (unique company row, enabled default false, url, encrypted token, thresholds, updated_by_id).
Tests
test/ksef_hub/service_config_test.exs, test/ksef_hub_web/live/settings_live/services_test.exs, test/ksef_hub/invoice_classifier/client_test.exs, test/ksef_hub/invoice_extractor/client_test.exs
New unit and LiveView tests covering lifecycle, validation, encryption, health-probe confirmation, and Application env isolation/setup updates.

Sequence Diagram

sequenceDiagram
    participant User
    participant LiveView as Services LiveView
    participant ServiceConfig as ServiceConfig Context
    participant DB as Database
    participant Health as Health Probe
    participant Encrypt as Encryption Service

    User->>LiveView: GET /c/:company_id/settings/services (mount)
    LiveView->>ServiceConfig: get_or_create_classifier_config(company_id)
    ServiceConfig->>DB: SELECT ... WHERE company_id
    alt not found
        ServiceConfig->>DB: INSERT default classifier_config
        DB-->>ServiceConfig: inserted row
    end
    ServiceConfig-->>LiveView: ClassifierConfig
    LiveView->>ServiceConfig: env_defaults()
    ServiceConfig-->>LiveView: defaults
    LiveView->>Health: async GET <url>/health (initial probe)
    Health-->>LiveView: {:ok} or {:error, reason}
    User->>LiveView: Submit "Save" with attrs
    LiveView->>Health: async pre-save GET <url>/health
    Health-->>LiveView: {:ok} or {:error, reason}
    alt health ok
        LiveView->>ServiceConfig: update_classifier_config(attrs, user_id)
        ServiceConfig->>Encrypt: encrypt(api_token) (if present)
        Encrypt-->>ServiceConfig: encrypted_blob
        ServiceConfig->>DB: UPDATE classifier_configs SET ...
        DB-->>ServiceConfig: updated row
        ServiceConfig-->>LiveView: {:ok, updated_config}
        LiveView->>User: flash success
    else health failed
        LiveView->>User: show "Save anyway?" confirmation
        User->>LiveView: confirm_save
        LiveView->>ServiceConfig: update_classifier_config(...)
        ServiceConfig->>DB: UPDATE ...
        DB-->>ServiceConfig: updated row
        ServiceConfig-->>LiveView: {:ok, updated_config}
        LiveView->>User: flash success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇
I nibble configs for each little den,
Encrypt the tokens, hop back again,
I poke the health endpoint before the save,
Then tuck each company's override in a cave,
Thump-thump — classifiers safe for every hen!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: per-company invoice classifier configuration' directly and clearly summarizes the primary change: adding per-company configuration capability for the invoice classifier service.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/per-company-classifier-config

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (7)
priv/repo/migrations/20260423052425_update_service_configurations_default_disabled.exs (1)

1-11: Consider squashing intermediate migrations before merge.

This migration modifies service_configurations, but the subsequent migration (20260423053709) drops this table entirely and replaces it with classifier_configs. As noted in ADR-0049, these intermediate migrations are "harmless but noisy."

For cleaner migration history, consider squashing the three migrations into a single migration that directly creates classifier_configs (only if no production data exists in service_configurations yet).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@priv/repo/migrations/20260423052425_update_service_configurations_default_disabled.exs`
around lines 1 - 11, The migration module
UpdateServiceConfigurationsDefaultDisabled updates service_configurations to set
enabled=false but a later migration removes that table and adds
classifier_configs, so squash these intermediate migrations by combining
UpdateServiceConfigurationsDefaultDisabled and the subsequent migrations into
one migration that directly creates classifier_configs (and removes any updates
to service_configurations); ensure you only do this squash if there is no
production data in service_configurations yet, otherwise keep history intact;
update the combined migration to create the classifier_configs schema and remove
or omit any UPDATE statements touching service_configurations (remove functions
up/down in UpdateServiceConfigurationsDefaultDisabled and replace with the
single create/drop logic for classifier_configs).
lib/ksef_hub/service_config.ex (2)

47-65: Use TrackedRepo.update for audit trail.

Similar to the insert case, updating classifier config is a user-visible settings change that should be tracked in the activity log.

♻️ Proposed change
-    Repo.update(changeset)
+    TrackedRepo.update(changeset)

As per coding guidelines: "Every context mutation affecting user-visible state must emit an activity event using TrackedRepo instead of Repo"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/service_config.ex` around lines 47 - 65, In
update_classifier_config/2 replace the direct Repo.update(changeset) call with
the tracked repository so the change emits an activity event (use
TrackedRepo.update instead of Repo.update); keep the same changeset construction
(including the api_token encryption logic in update_classifier_config and
ClassifierConfig.changeset) and add any required alias/import for TrackedRepo in
this module so compilation succeeds.

26-40: Consider using TrackedRepo for audit trail.

Per coding guidelines, context mutations affecting user-visible state should use TrackedRepo for automatic activity event emission. Creating a classifier config is a user-visible settings change.

♻️ Proposed change
+  alias KsefHub.ActivityLog.TrackedRepo

   def get_or_create_classifier_config(company_id) do
     case get_classifier_config(company_id) do
       nil ->
         %ClassifierConfig{company_id: company_id}
         |> Ecto.Changeset.change()
-        |> Repo.insert(on_conflict: :nothing, conflict_target: :company_id)
+        |> TrackedRepo.insert(on_conflict: :nothing, conflict_target: :company_id)

As per coding guidelines: "Every context mutation affecting user-visible state must emit an activity event using TrackedRepo instead of Repo"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/service_config.ex` around lines 26 - 40, The
get_or_create_classifier_config function is creating a user-visible settings
change using Repo.insert and Repo.get_by!, so replace those calls with
TrackedRepo variants to ensure activity events are emitted; specifically, update
the insert path that currently calls Repo.insert(on_conflict: :nothing,
conflict_target: :company_id) to use TrackedRepo.insert (preserving the
on_conflict and conflict_target options and the Ecto.Changeset from
%ClassifierConfig), and replace the fallback Repo.get_by!(ClassifierConfig,
company_id: company_id) with TrackedRepo.get_by!(ClassifierConfig, company_id:
company_id) so the creation path uses the tracked repository for audit trails.
lib/ksef_hub_web/live/settings_live/services.ex (3)

396-409: Handle trailing slashes in URL and narrow the rescue clause.

String concatenation doesn't account for URLs with trailing slashes (e.g., http://example.com/ becomes http://example.com//health). Also, the broad rescue can mask unexpected errors.

Proposed fix
 `@spec` check_url_health(String.t()) :: :ok | {:error, term()}
 defp check_url_health(url) do
+  health_url = URI.merge(url, "/health") |> to_string()
+
   case Req.get(
-          url: url <> "/health",
+          url: health_url,
          receive_timeout: 5_000,
          retry: false,
          connect_options: [timeout: 3_000]
        ) do
     {:ok, %{status: 200}} -> :ok
     {:ok, %{status: status}} -> {:error, {:http_error, status}}
     {:error, reason} -> {:error, reason}
   end
-rescue
-  e -> {:error, Exception.message(e)}
 end

Req.get already returns {:error, reason} for network/timeout issues, so the rescue is typically unnecessary unless guarding against malformed URLs—in which case, validate earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 396 - 409, In
check_url_health, avoid concatenating "/health" directly to url (which creates
"//health" if url has a trailing slash) by normalizing the URL before calling
Req.get (e.g., strip a trailing slash or use a proper URI join) and remove the
broad rescue; instead either let Req.get return {:error, reason} or rescue only
specific exceptions (e.g., ArgumentError) and return {:error, reason} from that
narrow rescue so unexpected errors are not masked; update check_url_health to
call Req.get with the normalized URL and eliminate the catch‑all rescue clause.

252-273: Clarify token clearing behavior.

Both nil and "" are treated as "keep existing" per the comment. However, the context function at lib/ksef_hub/service_config.ex:47-65 supports clearing via "". This means users have no UI path to clear a configured token—they can only overwrite it with a new value.

If this is intentional, consider adding a "Clear token" button or checkbox. If clearing should work, pass "" through instead of deleting it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 252 - 273, The
current handle_event("save", %{"classifier" => params}, socket) block treats
both nil and "" api_token values as "keep existing", which prevents the
service_config logic (which supports clearing via "") from clearing tokens;
change the params normalization so only nil removes the key (i.e., remove
Map.delete(params, "api_token") for the "" branch and allow the empty string to
pass through) so do_save/resolve_check_url and the downstream service_config
behavior can receive "" to clear the token (or alternatively implement a
dedicated "Clear token" UI control if intentional).

241-294: Consider adding @spec to callback function clauses.

The handle_event/3 and handle_info/2 clauses lack explicit @spec. While the Phoenix.LiveView behaviour defines these, per coding guidelines all functions should have type specifications.

Example for handle_event
`@impl` true
`@spec` handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
        {:noreply, Phoenix.LiveView.Socket.t()}
def handle_event("validate", %{"classifier" => params}, socket) do
  # ...
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 241 - 294, Add
explicit `@spec` annotations for the LiveView callbacks: declare a typespec for
handle_event/3 (e.g. `@spec` handle_event(String.t(), map(),
Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()}) and for
handle_info/2 (e.g. `@spec` handle_info(term(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}) and place each `@spec` immediately above
the first clause of the corresponding function (referencing handle_event/3 and
handle_info/2) so all clauses satisfy the module's coding guidelines.
test/ksef_hub_web/live/settings_live/services_test.exs (1)

116-133: Consider testing save via LiveView form submission.

This test calls the context function directly rather than exercising the LiveView save flow. This duplicates coverage already present in service_config_test.exs and doesn't verify the LiveView form handling, updated_by_id injection, or flash messages.

Suggested approach

Test the actual LiveView save flow instead:

test "saves classifier config via LiveView form", %{conn: conn, company: company, user: user} do
  {:ok, view, _html} = live(conn, ~p"/c/#{company.id}/settings/services")

  # Submit form with valid data (may need to mock health check)
  view
  |> form("form[phx-submit=save]", classifier: %{
    enabled: true,
    url: "http://custom:9000",
    category_confidence_threshold: "0.85",
    tag_confidence_threshold: "0.90"
  })
  |> render_submit()

  # Assert flash or verify persisted config
  config = ServiceConfig.get_classifier_config(company.id)
  assert config.url == "http://custom:9000"
  assert config.updated_by_id == user.id
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/settings_live/services_test.exs` around lines 116 -
133, Replace the direct context call test "saves classifier config via context"
with a LiveView integration test that opens the services page (use live(conn,
~p"/c/#{company.id}/settings/services")), submits the classifier form via the
view using form("form[phx-submit=save]", classifier: ...) and render_submit(),
then verify persistence via ServiceConfig.get_classifier_config(company.id) and
assert fields including url,
category_confidence_threshold/tag_confidence_threshold (as floats) and
updated_by_id == user.id; if the LiveView triggers external checks, stub/mocks
those health checks before submission so the form can submit successfully and
assert any flash if desired.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/sidecar-services.md`:
- Around line 148-149: Update the documentation to clarify that although the
admin UI exposes per-company classifier overrides (stored in
classifier_configs), the classification pipeline does not yet read those DB
overrides and still uses global Application.get_env settings; reference the
invoice classifier implementation (lib/ksef_hub/invoice_classifier.ex) and
ADR-0049 to state the per-company override UI is available but the runtime
wiring/integration is pending so DB values do not currently take precedence over
env vars.

In `@lib/ksef_hub/service_config/classifier_config.ex`:
- Around line 49-56: Remove :updated_by_id from the cast list inside
ClassifierConfig.changeset so this programmatic field is not accepted from
input; instead, in the service layer implement update_classifier_config/2 (or
update_classifier_config/3 if you prefer passing the id) to call
ClassifierConfig.changeset(attrs) and then set the actor explicitly with
Ecto.Changeset.put_change(:updated_by_id, updated_by_id) before persisting;
update any callers to pass the updated_by_id into update_classifier_config/2 so
the value is set server-side rather than via form input.

In
`@priv/repo/migrations/20260423053709_replace_service_configurations_with_classifier_configs.exs`:
- Around line 4-24: The migration currently uses change/0 and calls drop
table(:service_configurations), which is not reversible; replace change/0 with
explicit up/0 and down/0 functions: move the existing drop
table(:service_configurations), create table(:classifier_configs, primary_key:
false) ... and create unique_index(:classifier_configs, [:company_id]) into
up/0, and implement down/0 to drop :classifier_configs and recreate the original
:service_configurations table (including id :binary_id primary key, service_name
:string null: false, url :string, settings :map, enabled :boolean, timestamps()
and the unique_index on [:service_name]) so rollbacks succeed.

In `@test/ksef_hub_web/live/settings_live/services_test.exs`:
- Around line 140-154: The test currently calls
ServiceConfig.update_classifier_config(...) for config and other_config without
capturing results, which can hide failures; update the test to capture the
return tuples from ServiceConfig.update_classifier_config (for both the first
config and other_config) into variables and assert they indicate success (e.g.,
match {:ok, _updated} or equivalent) so failures surface, referencing
ServiceConfig.update_classifier_config and
ServiceConfig.get_or_create_classifier_config in the test.

In `@test/ksef_hub/service_config_test.exs`:
- Around line 137-149: The calls to ServiceConfig.update_classifier_config/2
discard their return values so failures like {:error, changeset} are hidden;
change the two calls for config_a and config_b to capture and assert their
results (e.g., pattern-match or assert {:ok, _} =
ServiceConfig.update_classifier_config(config_a, ...)) so any error is surfaced
immediately; reference the function ServiceConfig.update_classifier_config/2 and
the variables config_a and config_b when making the change.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 396-409: In check_url_health, avoid concatenating "/health"
directly to url (which creates "//health" if url has a trailing slash) by
normalizing the URL before calling Req.get (e.g., strip a trailing slash or use
a proper URI join) and remove the broad rescue; instead either let Req.get
return {:error, reason} or rescue only specific exceptions (e.g., ArgumentError)
and return {:error, reason} from that narrow rescue so unexpected errors are not
masked; update check_url_health to call Req.get with the normalized URL and
eliminate the catch‑all rescue clause.
- Around line 252-273: The current handle_event("save", %{"classifier" =>
params}, socket) block treats both nil and "" api_token values as "keep
existing", which prevents the service_config logic (which supports clearing via
"") from clearing tokens; change the params normalization so only nil removes
the key (i.e., remove Map.delete(params, "api_token") for the "" branch and
allow the empty string to pass through) so do_save/resolve_check_url and the
downstream service_config behavior can receive "" to clear the token (or
alternatively implement a dedicated "Clear token" UI control if intentional).
- Around line 241-294: Add explicit `@spec` annotations for the LiveView
callbacks: declare a typespec for handle_event/3 (e.g. `@spec`
handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: {:noreply,
Phoenix.LiveView.Socket.t()}) and for handle_info/2 (e.g. `@spec`
handle_info(term(), Phoenix.LiveView.Socket.t()) :: {:noreply,
Phoenix.LiveView.Socket.t()}) and place each `@spec` immediately above the first
clause of the corresponding function (referencing handle_event/3 and
handle_info/2) so all clauses satisfy the module's coding guidelines.

In `@lib/ksef_hub/service_config.ex`:
- Around line 47-65: In update_classifier_config/2 replace the direct
Repo.update(changeset) call with the tracked repository so the change emits an
activity event (use TrackedRepo.update instead of Repo.update); keep the same
changeset construction (including the api_token encryption logic in
update_classifier_config and ClassifierConfig.changeset) and add any required
alias/import for TrackedRepo in this module so compilation succeeds.
- Around line 26-40: The get_or_create_classifier_config function is creating a
user-visible settings change using Repo.insert and Repo.get_by!, so replace
those calls with TrackedRepo variants to ensure activity events are emitted;
specifically, update the insert path that currently calls
Repo.insert(on_conflict: :nothing, conflict_target: :company_id) to use
TrackedRepo.insert (preserving the on_conflict and conflict_target options and
the Ecto.Changeset from %ClassifierConfig), and replace the fallback
Repo.get_by!(ClassifierConfig, company_id: company_id) with
TrackedRepo.get_by!(ClassifierConfig, company_id: company_id) so the creation
path uses the tracked repository for audit trails.

In
`@priv/repo/migrations/20260423052425_update_service_configurations_default_disabled.exs`:
- Around line 1-11: The migration module
UpdateServiceConfigurationsDefaultDisabled updates service_configurations to set
enabled=false but a later migration removes that table and adds
classifier_configs, so squash these intermediate migrations by combining
UpdateServiceConfigurationsDefaultDisabled and the subsequent migrations into
one migration that directly creates classifier_configs (and removes any updates
to service_configurations); ensure you only do this squash if there is no
production data in service_configurations yet, otherwise keep history intact;
update the combined migration to create the classifier_configs schema and remove
or omit any UPDATE statements touching service_configurations (remove functions
up/down in UpdateServiceConfigurationsDefaultDisabled and replace with the
single create/drop logic for classifier_configs).

In `@test/ksef_hub_web/live/settings_live/services_test.exs`:
- Around line 116-133: Replace the direct context call test "saves classifier
config via context" with a LiveView integration test that opens the services
page (use live(conn, ~p"/c/#{company.id}/settings/services")), submits the
classifier form via the view using form("form[phx-submit=save]", classifier:
...) and render_submit(), then verify persistence via
ServiceConfig.get_classifier_config(company.id) and assert fields including url,
category_confidence_threshold/tag_confidence_threshold (as floats) and
updated_by_id == user.id; if the LiveView triggers external checks, stub/mocks
those health checks before submission so the form can submit successfully and
assert any flash if desired.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a80b414-5908-461c-b6ee-b20665534192

📥 Commits

Reviewing files that changed from the base of the PR and between bd9c148 and 09b8280.

📒 Files selected for processing (19)
  • .env.example
  • docs/adr/0049-per-company-classifier-config.md
  • docs/architecture.md
  • docs/infrastructure.md
  • docs/sidecar-services.md
  • lib/ksef_hub/authorization.ex
  • lib/ksef_hub/invoice_classifier.ex
  • lib/ksef_hub/service_config.ex
  • lib/ksef_hub/service_config/classifier_config.ex
  • lib/ksef_hub_web/components/settings_components.ex
  • lib/ksef_hub_web/live/settings_live/services.ex
  • lib/ksef_hub_web/router.ex
  • priv/repo/migrations/20260422215003_create_service_configurations.exs
  • priv/repo/migrations/20260423052425_update_service_configurations_default_disabled.exs
  • priv/repo/migrations/20260423053709_replace_service_configurations_with_classifier_configs.exs
  • test/ksef_hub/invoice_classifier/client_test.exs
  • test/ksef_hub/invoice_extractor/client_test.exs
  • test/ksef_hub/service_config_test.exs
  • test/ksef_hub_web/live/settings_live/services_test.exs

Comment thread docs/sidecar-services.md Outdated
Comment thread lib/ksef_hub/service_config/classifier_config.ex
Comment thread test/ksef_hub_web/live/settings_live/services_test.exs Outdated
Comment thread test/ksef_hub/service_config_test.exs Outdated
- Remove :updated_by_id from changeset cast; set server-side via
  put_change to prevent form input from overriding the actor
- Make replacement migration reversible with explicit up/down
- Assert return values of update_classifier_config in isolation tests
- Fix trailing-slash bug in health check URL construction
- Clarify docs that classifier pipeline doesn't yet read DB overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 10-11: The LiveView currently calls ServiceConfig.ClassifierConfig
directly for building and validating changesets, coupling the web layer to
internal module details; add a context helper
ServiceConfig.change_classifier_config(struct_or_params, attrs \\ %{}) that
returns the appropriate changeset (for new and existing ClassifierConfig) and
replace all direct calls to ServiceConfig.ClassifierConfig.* in this LiveView
(including the form-building and validation points where changesets are created)
with ServiceConfig.change_classifier_config/2 so the LiveView only calls the
ServiceConfig context API (e.g., replace any
ServiceConfig.ClassifierConfig.changeset(...) or new/empty struct usage with
ServiceConfig.change_classifier_config(classifier_config_or_parent, attrs)).
- Around line 265-267: The async health probes started with
Task.Supervisor.async_nolink(KsefHub.TaskSupervisor, fn -> {:pre_save_health,
params, check_url_health(url)} end) are racing and older task replies can
overwrite `@health` and pending_params; fix by storing the Task.async ref(s)
returned by Task.Supervisor.async_nolink in the LiveView assigns (e.g.,
:active_health_ref or a map of refs keyed by intent) when starting a probe, then
in handle_info/2 that handles the {:pre_save_health, ...} reply check the
incoming ref against the stored active ref(s) and drop the message if it doesn't
match, updating `@health/pending_params` only when the refs match; apply the same
pattern for the other probe locations mentioned (lines ~286-289 and 299-315) to
ensure stale tasks are ignored.
- Around line 253-259: The current params normalization removes the "" token
entirely so ServiceConfig.update_classifier_config/2 never receives a deliberate
empty-string clear request; change the logic so that only absent (nil) keys are
deleted but preserve "" values (or implement an explicit clear control like
"clear_api_token" and send "" only when that flag is set) — update the block
handling params["api_token"] (the Map.delete branches) so that "" is forwarded
to ServiceConfig.update_classifier_config/2 as the clear-token signal instead of
being removed unless you add and check a dedicated clear flag.

In `@lib/ksef_hub/service_config.ex`:
- Around line 48-75: The update_classifier_config function must stop reading
updated_by_id from the caller-supplied attrs and must use TrackedRepo to persist
changes so an activity event is emitted: change the function signature to accept
actor metadata (e.g., updated_by_id or actor) separately from attrs, remove
Map.get(attrs, "updated_by_id") / Map.get(attrs, :updated_by_id) usage, build
the changeset with ClassifierConfig.changeset and the api_token encryption logic
unchanged, then call TrackedRepo.update(changeset, actor: <actor-metadata>)
instead of Repo.update(changeset) so the actor is recorded server-side and the
required activity event is emitted.
- Around line 70-72: The current clause pattern-matches on
Encryption.encrypt(token) which will crash if it returns {:error, reason};
change the logic in the token when is_binary(token) branch to use a with (or
case) to call Encryption.encrypt(token) and, on {:ok, encrypted}, call
Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted), but on
{:error, reason} return the original changeset with an added error via
Ecto.Changeset.add_error(changeset, :api_token_encrypted, "encryption failed:
#{inspect(reason)}") so encryption failures become normal changeset errors
instead of raising.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 972be5e2-b8d5-4785-994d-5dd5a7ee4fff

📥 Commits

Reviewing files that changed from the base of the PR and between 09b8280 and 5e44069.

📒 Files selected for processing (7)
  • docs/sidecar-services.md
  • lib/ksef_hub/service_config.ex
  • lib/ksef_hub/service_config/classifier_config.ex
  • lib/ksef_hub_web/live/settings_live/services.ex
  • priv/repo/migrations/20260423053709_replace_service_configurations_with_classifier_configs.exs
  • test/ksef_hub/service_config_test.exs
  • test/ksef_hub_web/live/settings_live/services_test.exs
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/ksef_hub_web/live/settings_live/services_test.exs
  • docs/sidecar-services.md
  • lib/ksef_hub/service_config/classifier_config.ex
  • priv/repo/migrations/20260423053709_replace_service_configurations_with_classifier_configs.exs

Comment thread lib/ksef_hub_web/live/settings_live/services.ex Outdated
Comment thread lib/ksef_hub_web/live/settings_live/services.ex Outdated
Comment thread lib/ksef_hub_web/live/settings_live/services.ex Outdated
Comment thread lib/ksef_hub/service_config.ex Outdated
Comment on lines +70 to +72
token when is_binary(token) ->
{:ok, encrypted} = Encryption.encrypt(token)
Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle encryption failures without crashing the save flow.

Line 71 pattern-matches on Encryption.encrypt/1. If encryption returns {:error, reason}, this raises and turns a recoverable validation/persistence error into a LiveView crash. Convert that failure into a normal changeset error instead.

Possible fix
         token when is_binary(token) ->
-          {:ok, encrypted} = Encryption.encrypt(token)
-          Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)
+          with {:ok, encrypted} <- Encryption.encrypt(token) do
+            Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)
+          else
+            {:error, reason} ->
+              Ecto.Changeset.add_error(
+                changeset,
+                :api_token,
+                "could not be encrypted: #{inspect(reason)}"
+              )
+          end

As per coding guidelines, "Use with for multi-step operations that can fail in Elixir code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/service_config.ex` around lines 70 - 72, The current clause
pattern-matches on Encryption.encrypt(token) which will crash if it returns
{:error, reason}; change the logic in the token when is_binary(token) branch to
use a with (or case) to call Encryption.encrypt(token) and, on {:ok, encrypted},
call Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted), but
on {:error, reason} return the original changeset with an added error via
Ecto.Changeset.add_error(changeset, :api_token_encrypted, "encryption failed:
#{inspect(reason)}") so encryption failures become normal changeset errors
instead of raising.

…aces

- Add ServiceConfig.change_classifier_config/2 so LiveView no longer
  calls ClassifierConfig.changeset directly
- Use TrackedRepo.update with actor opts for activity log emission
  instead of Repo.update with updated_by_id in form attrs
- Track active_health_ref to discard stale async health probe replies
- Revert .env.example to main state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
lib/ksef_hub_web/live/settings_live/services.ex (1)

259-265: ⚠️ Potential issue | 🟠 Major

Keep the explicit blank-token clear path.

Deleting "" here means ServiceConfig.update_classifier_config/3 never receives the clear-token signal it already supports. From this screen, once a company token is stored, there is no way to remove it without turning the whole override off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 259 - 265, The
current params normalization removes empty-string values, which prevents
ServiceConfig.update_classifier_config/3 from receiving the explicit clear-token
signal; change the params handling in the api_token branch so nil still deletes
the key but "" is preserved (do not Map.delete params when params["api_token"]
== ""), ensuring the params map passes an explicit "" value to
ServiceConfig.update_classifier_config/3 so the clear-token path remains
reachable.
🧹 Nitpick comments (1)
test/ksef_hub_web/live/settings_live/services_test.exs (1)

90-166: Add a regression test for the failed-probe edit/resubmit flow.

The current suite never covers “health check fails → user edits the form → Save anyway / resubmit”, which is exactly where stale pending_params can slip through. A LiveView test for that path would lock down the confirmation-state handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/settings_live/services_test.exs` around lines 90 -
166, Add a LiveView test in the existing "saving" describe block that reproduces
the failed-probe → edit → resubmit path: arrange a classifier config and
stub/mock the health check (probe) to fail on the first save, mount the live
view via live(conn, ~p"/c/#{company.id}/settings/services"), submit the form
(use form("form[phx-submit=save]", classifier: ...)) to trigger the failing
probe and assert the probe-failure error is rendered, then simulate the user
editing the form (e.g., change the url field with render_change) and resubmit
(render_submit), finally assert the ServiceConfig persisted the new value (via
ServiceConfig.get_or_create_classifier_config or ServiceConfig.reload) to ensure
the edited value, not stale pending_params, was saved; reference symbols:
live/1, form("form[phx-submit=save]"), render_change, render_submit,
ServiceConfig.get_or_create_classifier_config, and
ServiceConfig.update_classifier_config.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 293-295: The handler handle_event("check_health", _params, socket)
is using the persisted socket.assigns.config instead of the current unsaved form
inputs; change it to derive the config from the live form/changeset before
calling check_health_async (e.g. use socket.assigns.changeset |>
Ecto.Changeset.apply_changes() or read socket.assigns.form data to build the
temp config) and pass that temporary config (and current env_defaults from the
form if applicable) into check_health_async, then assign health: :checking and
active_health_ref as before.
- Around line 419-430: Reject private or internal targets before calling
check_url_health by parsing the URL (use URI.parse) and validating scheme is
http/https, resolving the hostname to one or more IPs (e.g., :inet.getaddr /
:inet_res.lookup) and refusing any address in loopback, link-local, RFC1918
(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), carrier-grade (100.64.0.0/10), IPv6
loopback/ULA/ULA equivalents, and known metadata addresses like 169.254.169.254;
also prevent following redirects by setting Req option redirect: false so probes
can't chain to internal targets. Implement this check inside or called from
check_url_health (and name the helper e.g., host_allowed?/1 or ip_allowed?/1)
and return {:error, :disallowed_target} before making the Req.get call when the
host/IP is disallowed.
- Around line 248-255: In handle_event("validate", %{"classifier" => params},
socket) clear any pending confirmation state so stale pending_params aren't
used: when building the changeset and assigning form (to_form(changeset, as:
:classifier)) also reset pending_params (set to nil or %{}) and
pending_confirmation (false) on the socket assigns; update the assign(socket,
...) call to include those keys so editing the form removes the previous failed
pre-save probe state before validation.

---

Duplicate comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 259-265: The current params normalization removes empty-string
values, which prevents ServiceConfig.update_classifier_config/3 from receiving
the explicit clear-token signal; change the params handling in the api_token
branch so nil still deletes the key but "" is preserved (do not Map.delete
params when params["api_token"] == ""), ensuring the params map passes an
explicit "" value to ServiceConfig.update_classifier_config/3 so the clear-token
path remains reachable.

---

Nitpick comments:
In `@test/ksef_hub_web/live/settings_live/services_test.exs`:
- Around line 90-166: Add a LiveView test in the existing "saving" describe
block that reproduces the failed-probe → edit → resubmit path: arrange a
classifier config and stub/mock the health check (probe) to fail on the first
save, mount the live view via live(conn,
~p"/c/#{company.id}/settings/services"), submit the form (use
form("form[phx-submit=save]", classifier: ...)) to trigger the failing probe and
assert the probe-failure error is rendered, then simulate the user editing the
form (e.g., change the url field with render_change) and resubmit
(render_submit), finally assert the ServiceConfig persisted the new value (via
ServiceConfig.get_or_create_classifier_config or ServiceConfig.reload) to ensure
the edited value, not stale pending_params, was saved; reference symbols:
live/1, form("form[phx-submit=save]"), render_change, render_submit,
ServiceConfig.get_or_create_classifier_config, and
ServiceConfig.update_classifier_config.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2edc116c-2db9-4b65-80ab-6cc2a5eeb7a8

📥 Commits

Reviewing files that changed from the base of the PR and between 5e44069 and ad673e0.

📒 Files selected for processing (3)
  • lib/ksef_hub/service_config.ex
  • lib/ksef_hub_web/live/settings_live/services.ex
  • test/ksef_hub_web/live/settings_live/services_test.exs

Comment on lines +248 to +255
def handle_event("validate", %{"classifier" => params}, socket) do
changeset =
socket.assigns.config
|> ServiceConfig.change_classifier_config(params)
|> Map.put(:action, :validate)

{:noreply, assign(socket, form: to_form(changeset, as: :classifier))}
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear the pending confirmation state when the form changes.

After a failed pre-save probe, pending_params still holds the old submission. If the user edits the form, the warning remains visible and "Save anyway" will persist the stale params from the previous attempt, not the current form state.

Possible fix
   def handle_event("validate", %{"classifier" => params}, socket) do
     changeset =
       socket.assigns.config
       |> ServiceConfig.change_classifier_config(params)
       |> Map.put(:action, :validate)

-    {:noreply, assign(socket, form: to_form(changeset, as: :classifier))}
+    {:noreply,
+     assign(socket,
+       form: to_form(changeset, as: :classifier),
+       confirm_save: false,
+       pending_params: nil
+     )}
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 248 - 255, In
handle_event("validate", %{"classifier" => params}, socket) clear any pending
confirmation state so stale pending_params aren't used: when building the
changeset and assigning form (to_form(changeset, as: :classifier)) also reset
pending_params (set to nil or %{}) and pending_confirmation (false) on the
socket assigns; update the assign(socket, ...) call to include those keys so
editing the form removes the previous failed pre-save probe state before
validation.

Comment thread lib/ksef_hub_web/live/settings_live/services.ex
Comment thread lib/ksef_hub_web/live/settings_live/services.ex
emilwojtaszek and others added 2 commits April 23, 2026 10:50
- Add host_allowed?/1 SSRF guard to check_url_health that rejects
  loopback, RFC1918, link-local, carrier-grade NAT, and IPv6 private
  addresses before making HTTP requests; disable redirect following
- check_health button now probes the URL from the live form, not the
  persisted config
- validate event clears pending_params and confirm_save so editing the
  form after a failed probe doesn't leave stale state
- Preserve "" api_token in save params so the clear-token path in
  update_classifier_config/3 remains reachable
- Add LiveView test for failed-probe → edit → resubmit flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract resolve_ip/1 helper so resolved_ip_allowed?/1 stays within
credo's max nesting depth of 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 254-259: The form-change branches should clear any in-flight
health probe so stale replies aren't applied; update the assigns where you clear
the form/pending_params/confirm_save (e.g., the assign call that sets form:
to_form(changeset, as: :classifier), pending_params: nil, confirm_save: false)
to also set active_health_ref: nil, and make the same change in the other
similar block (the one around lines 314-339) so handle_info/2 no longer treats
old probe replies as current.
- Around line 336-339: The current branch lets any non-:ok health_result flow to
the "Save anyway" override; change the case on health_result in the function
handling the save so that disallowed internal targets cannot be overridden: keep
the :ok -> do_save(socket, params) path, add an explicit clause for the
disallowed/internal-target result (e.g., :disallowed_target or whatever sentinel
the health check returns) that returns {:noreply, assign(socket, confirm_save:
false, pending_params: nil, error: :disallowed_target)} (or similar) and only
allow confirm_save: true for recoverable errors (e.g., network/timeouts). Use
host_allowed?/1 earlier or in that branch to double-check the URL and ensure
do_save, confirm_save and pending_params are not set when the host is
private/loopback.
- Around line 447-463: The current host validation uses :inet.getaddr/2 (in the
block where host = String.to_charlist(uri.host) and case :inet.getaddr(host,
:inet) do) which only checks a single resolved IP; change this to use
:inet.getaddrs/2 (or inet_res:lookup/3) to obtain all A/AAAA addresses for the
host and then iterate over the full list, calling ip4_private?/1 or
ip6_private?/1 for each address; if any resolved address is private/internal,
return {:error, :disallowed_target}, otherwise return :ok. Ensure both IPv4 and
IPv6 lists are fully validated and reuse the existing ip4_private?/1 and
ip6_private?/1 helpers and the same uri.scheme/uri.host checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 733acbfd-3cd5-4ad6-8d3d-32610d872835

📥 Commits

Reviewing files that changed from the base of the PR and between ad673e0 and db3fe6d.

📒 Files selected for processing (2)
  • lib/ksef_hub_web/live/settings_live/services.ex
  • test/ksef_hub_web/live/settings_live/services_test.exs
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/ksef_hub_web/live/settings_live/services_test.exs

Comment on lines +254 to +259
{:noreply,
assign(socket,
form: to_form(changeset, as: :classifier),
pending_params: nil,
confirm_save: false
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalidate the active probe when the form changes.

validate clears the confirmation state, but it leaves active_health_ref intact. If an earlier "save" or "check_health" task finishes after the user edits the form, handle_info/2 still treats that reply as current and can either repopulate the warning with old params or call do_save/2 for the previous submission.

Suggested fix
     {:noreply,
      assign(socket,
        form: to_form(changeset, as: :classifier),
+       health: nil,
+       active_health_ref: nil,
        pending_params: nil,
        confirm_save: false
      )}

Also applies to: 314-339

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 254 - 259, The
form-change branches should clear any in-flight health probe so stale replies
aren't applied; update the assigns where you clear the
form/pending_params/confirm_save (e.g., the assign call that sets form:
to_form(changeset, as: :classifier), pending_params: nil, confirm_save: false)
to also set active_health_ref: nil, and make the same change in the other
similar block (the one around lines 314-339) so handle_info/2 no longer treats
old probe replies as current.

Comment thread lib/ksef_hub_web/live/settings_live/services.ex
Comment thread lib/ksef_hub_web/live/settings_live/services.ex Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
lib/ksef_hub_web/live/settings_live/services.ex (3)

336-339: ⚠️ Potential issue | 🟠 Major

Do not offer Save anyway for blocked targets.

{:error, :disallowed_target} is a hard validation failure, not a transient outage. This branch currently lets a private/loopback URL be persisted by clicking Save anyway.

Possible fix
       case health_result do
         :ok -> do_save(socket, params)
-        _error -> {:noreply, assign(socket, confirm_save: true, pending_params: params)}
+        {:error, :disallowed_target} ->
+          changeset =
+            socket.assigns.config
+            |> ServiceConfig.change_classifier_config(params)
+            |> Ecto.Changeset.add_error(:url, "must point to a public host")
+
+          {:noreply,
+           assign(socket,
+             form: to_form(changeset, as: :classifier),
+             confirm_save: false,
+             pending_params: nil
+           )}
+
+        _error ->
+          {:noreply, assign(socket, confirm_save: true, pending_params: params)}
       end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 336 - 339, The
current case on health_result treats all non-:ok results the same and sets
confirm_save to true, which allows a "Save anyway" for hard validation failures;
update the case to specially handle the {:error, :disallowed_target} result
(from health_result) so it does not set confirm_save or allow do_save — instead
assign an appropriate error state on the socket (e.g. set an error message or
validation flag) and return {:noreply, socket} for that branch; keep the
existing behavior for transient errors (the fallback branch) to set
confirm_save: true and pending_params as before.

254-259: ⚠️ Potential issue | 🟠 Major

Invalidate the in-flight probe when the form changes.

Clearing only pending_params/confirm_save still leaves the previous active_health_ref live. If an earlier "save" probe returns after the user edits the form, handle_info/2 can still apply or even persist the old params.

Possible fix
     {:noreply,
      assign(socket,
        form: to_form(changeset, as: :classifier),
+       health: nil,
+       active_health_ref: nil,
        pending_params: nil,
        confirm_save: false
      )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/settings_live/services.ex` around lines 254 - 259, When
resetting the form (the assign(...) call that sets form, pending_params and
confirm_save), also invalidate any in-flight probe by clearing the live
reference stored as active_health_ref and cancelling any monitor/timer for it;
specifically set active_health_ref: nil in that assign and if you store a
monitor or timer ref (the value used to track the in-flight "save" probe), call
Process.demonitor(monitor_ref, [:flush]) or :erlang.cancel_timer(timer_ref)
before clearing so a late handle_info/2 for the old probe cannot apply or
persist stale params.

456-478: ⚠️ Potential issue | 🔴 Critical

Validate every resolved A/AAAA record, not just the first one.

:inet.getaddr/2 only checks one resolved IP. A hostname with mixed public/private DNS answers can pass resolved_ip_allowed?/1 while the HTTP client connects to a different private record, which reopens the SSRF path.

Does Erlang/OTP `:inet.getaddr/2` return only a single resolved address, and should `:inet.getaddrs/2` (or `:inet_res.lookup/3`) be used to validate all A/AAAA records before allowing an HTTP target?
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@lib/ksef_hub_web/live/settings_live/services.ex`:
- Around line 336-339: The current case on health_result treats all non-:ok
results the same and sets confirm_save to true, which allows a "Save anyway" for
hard validation failures; update the case to specially handle the {:error,
:disallowed_target} result (from health_result) so it does not set confirm_save
or allow do_save — instead assign an appropriate error state on the socket (e.g.
set an error message or validation flag) and return {:noreply, socket} for that
branch; keep the existing behavior for transient errors (the fallback branch) to
set confirm_save: true and pending_params as before.
- Around line 254-259: When resetting the form (the assign(...) call that sets
form, pending_params and confirm_save), also invalidate any in-flight probe by
clearing the live reference stored as active_health_ref and cancelling any
monitor/timer for it; specifically set active_health_ref: nil in that assign and
if you store a monitor or timer ref (the value used to track the in-flight
"save" probe), call Process.demonitor(monitor_ref, [:flush]) or
:erlang.cancel_timer(timer_ref) before clearing so a late handle_info/2 for the
old probe cannot apply or persist stale params.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b171058-8df0-45ea-ba2e-8acc731696ba

📥 Commits

Reviewing files that changed from the base of the PR and between db3fe6d and 5ce5a9f.

📒 Files selected for processing (1)
  • lib/ksef_hub_web/live/settings_live/services.ex

… IPs

- Validate handler now nils active_health_ref so in-flight probe
  replies are discarded when the user edits the form
- SSRF-blocked URLs (disallowed_target) show a flash error and never
  offer "Save anyway"; only recoverable errors allow the override
- Resolve ALL A/AAAA records via :inet.getaddrs/2 and reject the host
  if any address is private
- Update test to verify SSRF blocking behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit c3de4ef into main Apr 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant