# Alerting Plan 01 — Outbound HTTP Infrastructure + Outbound Connections

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a cross-cutting outbound HTTP module (`http/`) and an admin-managed outbound connections feature (`outbound/`) — reusable infrastructure that future alerting webhooks (and existing OIDC, and any future outbound integration) build on. Admins can CRUD outbound destinations, configure TLS trust, test reachability, and restrict availability by environment.

**Architecture:** Two new peer modules in `cameleer-server-core` (domain) and `cameleer-server-app` (implementation). Shared `OutboundHttpClientFactory` owns TLS trust composition (JVM default + configured CA PEMs + optional trust-all). `OutboundConnection` is an admin-managed PG-persisted entity carrying a URL, default headers/body template, per-connection TLS override, HMAC secret (encrypted at rest), and allowed-environment restriction. Admin REST + a new admin UI page consume it. Tenant-global, env-restrictable. No alerting tables yet — those arrive in Plan 02.

**Tech stack:** Spring Boot 3.4.3, JdbcTemplate, Flyway, Apache HttpClient 5 (already in tree), Bean Validation, Postgres, React 18 + TanStack Query.

**Plan 02/03 dependencies:** Plan 02 (alerting backend) will consume `OutboundHttpClientFactory` for webhook dispatch and reference `OutboundConnection` rows from rule JSONB. Plan 03 (alerting UI) will reuse the query hooks added here.

**Planning-phase decisions resolved in this plan:**
- **§20 item 2 — `hmac_secret` encryption at rest:** bespoke symmetric cipher derived from the existing Ed25519/JWT key material. No new dep. See Task 7.
- **§20 item 1 — OIDC alignment:** OIDC outbound cert handling audited in Task 4 after `SslContextBuilder` exists. Retrofit of OIDC to use the factory is **deliberately deferred to a follow-up** so Plan 01 ships without touching proven-working auth code. Tracked informally in the commit messages; no new backlog needed.

---

## File Structure

### New files (backend — core, no Spring)

- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java` — enum
- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java` — record of system-wide config
- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java` — record of per-call TLS/timeout overrides
- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java` — interface
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java` — enum `POST|PUT|PATCH`
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java` — enum `NONE|BEARER|BASIC`
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java` — sealed interface + subtypes
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java` — record
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java` — interface
- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java` — interface

### New files (backend — app)

- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java`
- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java`
- `cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql`

### New files (UI)

- `ui/src/api/queries/admin/outboundConnections.ts`
- `ui/src/pages/Admin/OutboundConnectionsPage.tsx`
- `ui/src/pages/Admin/OutboundConnectionEditor.tsx`

### Test files

- `cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java`
- `cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java`
- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java`
- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java`
- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java`
- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java`
- `ui/src/pages/Admin/OutboundConnectionsPage.test.tsx`
- `ui/src/pages/Admin/OutboundConnectionEditor.test.tsx`

### Modified files

- `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` — add two new categories
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` — path matchers for new admin endpoints
- `cameleer-server-app/src/main/resources/application.yml` — default `cameleer.server.outbound-http.*` values
- `ui/src/router.tsx` — route entry for admin outbound connections
- `ui/src/pages/Admin/AdminLayout.tsx` — nav entry (find the admin nav list)
- `.claude/rules/core-classes.md` — new packages documented
- `.claude/rules/app-classes.md` — new packages, new controller, new admin endpoints

---

## Conventions

- **Testcontainers** is the existing IT pattern. See `ConfigEnvIsolationIT`, `ClickHouseStatsStoreIT` for working setups.
- **MockMvc** is used for controller integration tests. See `ThresholdAdminControllerIT`.
- **Commit message prefix** follows existing repo convention: `feat(outbound): …`, `test(outbound): …`, `docs(rules): …`.
- **Java 17**, records, sealed interfaces, pattern-matching switches allowed.
- **Maven build:** `mvn -pl cameleer-server-app -am verify` for fast feedback on changed modules. Full build `mvn clean verify`.
- **TDD cadence:** write failing test → run it (must fail for the expected reason) → write minimum code → run again (must pass) → commit. One logical unit per commit. No squashing across tasks.
- **No `@Autowired` field injection.** Constructor injection always.

---

## Phase 1 — Database migration

### Task 1: Write `V11__outbound_connections.sql`

**Files:**
- Create: `cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql`

- [ ] **Step 1: Write the migration**

```sql
-- V11 — Outbound connections (admin-managed HTTPS destinations)
-- See: docs/superpowers/specs/2026-04-19-alerting-design.md §6

CREATE TYPE trust_mode_enum           AS ENUM ('SYSTEM_DEFAULT','TRUST_ALL','TRUST_PATHS');
CREATE TYPE outbound_method_enum      AS ENUM ('POST','PUT','PATCH');
CREATE TYPE outbound_auth_kind_enum   AS ENUM ('NONE','BEARER','BASIC');

CREATE TABLE outbound_connections (
  id                        uuid PRIMARY KEY,
  tenant_id                 varchar(64) NOT NULL,
  name                      varchar(100) NOT NULL,
  description               text,
  url                       text NOT NULL,
  method                    outbound_method_enum NOT NULL,
  default_headers           jsonb NOT NULL DEFAULT '{}',
  default_body_tmpl         text,
  tls_trust_mode            trust_mode_enum NOT NULL DEFAULT 'SYSTEM_DEFAULT',
  tls_ca_pem_paths          jsonb NOT NULL DEFAULT '[]',
  hmac_secret_ciphertext    text,
  auth_kind                 outbound_auth_kind_enum NOT NULL DEFAULT 'NONE',
  auth_config               jsonb NOT NULL DEFAULT '{}',
  allowed_environment_ids   uuid[] NOT NULL DEFAULT '{}',
  created_at                timestamptz NOT NULL DEFAULT now(),
  created_by                uuid NOT NULL REFERENCES users(id),
  updated_at                timestamptz NOT NULL DEFAULT now(),
  updated_by                uuid NOT NULL REFERENCES users(id),
  CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name)
);

CREATE INDEX outbound_connections_tenant_idx ON outbound_connections (tenant_id);
```

- [ ] **Step 2: Start backend to run migration**

Run: `mvn -pl cameleer-server-app -am spring-boot:run` (or whatever the project's existing `make dev` / IDE launcher is).

Verify in the log that `Flyway Community Edition ... Successfully applied 1 migration to schema "tenant_default", now at version v11 (outbound connections)` appears.

- [ ] **Step 3: Verify schema via psql or DB admin endpoint**

```bash
psql -d cameleer -c "\d outbound_connections" -c "\dT+ trust_mode_enum"
```

Expected: table exists with all columns; enums defined.

- [ ] **Step 4: Commit**

```bash
git add cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql
git commit -m "feat(outbound): V11 flyway migration for outbound_connections table"
```

---

## Phase 2 — Cross-cutting HTTP module (`core/http/`)

### Task 2: Core records and interface

**Files:**
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java`

- [ ] **Step 1: `TrustMode`**

```java
package com.cameleer.server.core.http;

public enum TrustMode {
    SYSTEM_DEFAULT,
    TRUST_ALL,
    TRUST_PATHS
}
```

- [ ] **Step 2: `OutboundHttpProperties`**

```java
package com.cameleer.server.core.http;

import java.time.Duration;
import java.util.List;

public record OutboundHttpProperties(
        boolean trustAll,
        List<String> trustedCaPemPaths,
        Duration defaultConnectTimeout,
        Duration defaultReadTimeout,
        String proxyUrl,
        String proxyUsername,
        String proxyPassword
) {
    public OutboundHttpProperties {
        trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths);
        if (defaultConnectTimeout == null) defaultConnectTimeout = Duration.ofMillis(2000);
        if (defaultReadTimeout == null)    defaultReadTimeout = Duration.ofMillis(5000);
    }
}
```

- [ ] **Step 3: `OutboundHttpRequestContext`**

```java
package com.cameleer.server.core.http;

import java.time.Duration;
import java.util.List;

public record OutboundHttpRequestContext(
        TrustMode trustMode,
        List<String> trustedCaPemPaths,
        Duration connectTimeout,
        Duration readTimeout
) {
    public OutboundHttpRequestContext {
        if (trustMode == null) trustMode = TrustMode.SYSTEM_DEFAULT;
        trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths);
    }

    public static OutboundHttpRequestContext systemDefault() {
        return new OutboundHttpRequestContext(TrustMode.SYSTEM_DEFAULT, List.of(), null, null);
    }
}
```

- [ ] **Step 4: `OutboundHttpClientFactory` interface**

```java
package com.cameleer.server.core.http;

import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;

public interface OutboundHttpClientFactory {
    /** Returns a memoized client configured for the given TLS/timeout context. */
    CloseableHttpClient clientFor(OutboundHttpRequestContext context);
}
```

- [ ] **Step 5: Compile**

Run: `mvn -pl cameleer-server-core compile`
Expected: BUILD SUCCESS.

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-core/src/main/java/com/cameleer/server/core/http/
git commit -m "feat(http): core outbound HTTP interfaces and property records"
```

---

## Phase 3 — HTTP module implementation (`app/http/`)

### Task 3: `SslContextBuilder` with unit tests

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java`

- [ ] **Step 1: Write the failing test first**

Use a small helper that generates a self-signed cert to a temp PEM file. JDK's `KeyPairGenerator` + `sun.security.x509.*` works but is fragile; easier: check in a hand-written test PEM under `src/test/resources/test-ca.pem` (a simple self-signed cert you generate once with `openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -out test-ca.pem -days 365 -subj "/CN=test"`).

```java
package com.cameleer.server.app.http;

import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.http.TrustMode;
import org.junit.jupiter.api.Test;

import javax.net.ssl.SSLContext;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class SslContextBuilderTest {
    private final OutboundHttpProperties systemProps =
            new OutboundHttpProperties(false, List.of(), Duration.ofMillis(2000), Duration.ofMillis(5000),
                                        null, null, null);
    private final SslContextBuilder builder = new SslContextBuilder();

    @Test
    void systemDefaultUsesJdkTrustStore() throws Exception {
        SSLContext ctx = builder.build(systemProps, OutboundHttpRequestContext.systemDefault());
        assertThat(ctx).isNotNull();
        assertThat(ctx.getProtocol()).isEqualTo("TLS");
    }

    @Test
    void trustAllSkipsValidation() throws Exception {
        SSLContext ctx = builder.build(systemProps,
                new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null));
        assertThat(ctx).isNotNull();
    }

    @Test
    void trustPathsLoadsPemFile() throws Exception {
        Path pem = Path.of("src/test/resources/test-ca.pem");
        assertThat(pem).exists();
        SSLContext ctx = builder.build(systemProps,
                new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of(pem.toString()), null, null));
        assertThat(ctx).isNotNull();
    }

    @Test
    void trustPathsMissingFileThrows() {
        assertThatThrownBy(() -> builder.build(systemProps,
                new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of("/no/such/file.pem"), null, null)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("CA file not found");
    }
}
```

- [ ] **Step 2: Run test, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=SslContextBuilderTest`
Expected: all tests fail with "ClassNotFoundException: SslContextBuilder" or similar.

- [ ] **Step 3: Generate test-ca.pem fixture**

```bash
cd cameleer-server-app/src/test/resources
openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -out test-ca.pem -days 3650 -subj "/CN=cameleer-test-ca"
```

- [ ] **Step 4: Implement `SslContextBuilder`**

```java
package com.cameleer.server.app.http;

import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class SslContextBuilder {

    public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx) throws Exception {
        SSLContext sslContext = SSLContext.getInstance("TLS");

        if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) {
            sslContext.init(null, new TrustManager[]{new TrustAllManager()}, null);
            return sslContext;
        }

        List<X509Certificate> extraCerts = new ArrayList<>();
        List<String> paths = new ArrayList<>(systemProps.trustedCaPemPaths());
        if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) {
            paths.addAll(ctx.trustedCaPemPaths());
        }
        for (String p : paths) {
            extraCerts.addAll(loadPem(p));
        }

        KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
        ks.load(null, null);

        // Load JDK default trust roots
        TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        defaultTmf.init((KeyStore) null);
        int i = 0;
        for (TrustManager tm : defaultTmf.getTrustManagers()) {
            if (tm instanceof X509TrustManager x509tm) {
                for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
                    ks.setCertificateEntry("default-" + (i++), cert);
                }
            }
        }
        // Add configured extras
        for (X509Certificate cert : extraCerts) {
            ks.setCertificateEntry("extra-" + (i++), cert);
        }

        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(ks);
        sslContext.init(null, tmf.getTrustManagers(), null);
        return sslContext;
    }

    private Collection<? extends X509Certificate> loadPem(String path) throws IOException, java.security.cert.CertificateException {
        Path p = Path.of(path);
        if (!Files.exists(p)) {
            throw new IllegalArgumentException("CA file not found: " + path);
        }
        try (var in = Files.newInputStream(p)) {
            CertificateFactory cf = CertificateFactory.getInstance("X.509");
            @SuppressWarnings("unchecked")
            Collection<X509Certificate> certs = (Collection<X509Certificate>) cf.generateCertificates(in);
            return certs;
        }
    }

    private static final class TrustAllManager implements X509TrustManager {
        @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
        @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
        @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
    }
}
```

- [ ] **Step 5: Run test, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=SslContextBuilderTest`

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java \
        cameleer-server-app/src/test/resources/test-ca.pem
git commit -m "feat(http): SslContextBuilder supports system/trust-all/trust-paths modes"
```

### Task 4: `ApacheOutboundHttpClientFactory` + startup config + OIDC audit note

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java`
- Modify: `cameleer-server-app/src/main/resources/application.yml` (add default `cameleer.server.outbound-http` section)

- [ ] **Step 1: Write integration test (embedded HTTPS server)**

Use WireMock with HTTPS enabled (`httpsPort(0)`, self-signed cert). Confirm the factory respects trust-all for the WireMock self-signed cert.

```java
package com.cameleer.server.app.http;

import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.http.TrustMode;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.util.List;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ApacheOutboundHttpClientFactoryIT {

    private WireMockServer wm;
    private ApacheOutboundHttpClientFactory factory;

    @BeforeEach
    void setUp() {
        wm = new WireMockServer(WireMockConfiguration.options()
                .httpDisabled(true).dynamicHttpsPort());
        wm.start();
        wm.stubFor(get("/ping").willReturn(ok("pong")));

        OutboundHttpProperties props = new OutboundHttpProperties(
                false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null);
        factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder());
    }

    @AfterEach void tearDown() { wm.stop(); }

    @Test
    void trustAllBypassesWiremockSelfSignedCert() throws Exception {
        CloseableHttpClient client = factory.clientFor(
                new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null));
        try (var resp = client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) {
            assertThat(resp.getCode()).isEqualTo(200);
            assertThat(EntityUtils.toString(resp.getEntity())).isEqualTo("pong");
        }
    }

    @Test
    void systemDefaultRejectsSelfSignedCert() {
        CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault());
        assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping")))
                .hasMessageContaining("PKIX");
    }

    @Test
    void clientsAreMemoizedByContext() {
        CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
        CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
        assertThat(c1).isSameAs(c2);
    }
}
```

- [ ] **Step 2: Run test, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=ApacheOutboundHttpClientFactoryIT`
Expected: FAIL — class not found.

- [ ] **Step 3: Implement `ApacheOutboundHttpClientFactory`**

```java
package com.cameleer.server.app.http;

import com.cameleer.server.core.http.OutboundHttpClientFactory;
import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
import org.apache.hc.core5.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory {
    private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class);

    private final OutboundHttpProperties systemProps;
    private final SslContextBuilder sslBuilder;
    private final ConcurrentMap<CacheKey, CloseableHttpClient> clients = new ConcurrentHashMap<>();

    public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) {
        this.systemProps = systemProps;
        this.sslBuilder = sslBuilder;
    }

    @Override
    public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) {
        CacheKey key = CacheKey.of(systemProps, ctx);
        return clients.computeIfAbsent(key, k -> build(ctx));
    }

    private CloseableHttpClient build(OutboundHttpRequestContext ctx) {
        try {
            var sslContext = sslBuilder.build(systemProps, ctx);
            var sslFactory = SSLConnectionSocketFactoryBuilder.create()
                    .setSslContext(sslContext)
                    .build();
            var connMgr = PoolingHttpClientConnectionManagerBuilder.create()
                    .setSSLSocketFactory(sslFactory)
                    .setDefaultConnectionConfig(ConnectionConfig.custom()
                            .setConnectTimeout(Timeout.of(
                                    ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout()))
                            .setSocketTimeout(Timeout.of(
                                    ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout()))
                            .build())
                    .build();
            var requestConfig = RequestConfig.custom().build();
            return HttpClients.custom()
                    .setConnectionManager(connMgr)
                    .setDefaultRequestConfig(requestConfig)
                    .build();
        } catch (Exception e) {
            throw new IllegalStateException("Failed to build outbound HTTP client", e);
        }
    }

    /** Key preserves the trust material used so that a change in system props produces a new client. */
    private record CacheKey(boolean trustAll, List<String> caPaths, com.cameleer.server.core.http.TrustMode mode,
                            java.time.Duration connect, java.time.Duration read) {
        static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) {
            return new CacheKey(sp.trustAll(),
                    List.copyOf(java.util.stream.Stream.concat(sp.trustedCaPemPaths().stream(),
                            ctx.trustedCaPemPaths().stream()).toList()),
                    ctx.trustMode(),
                    ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(),
                    ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout());
        }
    }
}
```

- [ ] **Step 4: Implement `OutboundHttpConfig` with @ConfigurationProperties binding and startup validation**

```java
package com.cameleer.server.app.http.config;

import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory;
import com.cameleer.server.app.http.SslContextBuilder;
import com.cameleer.server.core.http.OutboundHttpClientFactory;
import com.cameleer.server.core.http.OutboundHttpProperties;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;

@Configuration
@ConfigurationProperties(prefix = "cameleer.server.outbound-http")
public class OutboundHttpConfig {
    private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class);

    private boolean trustAll = false;
    private List<String> trustedCaPemPaths = List.of();
    private long defaultConnectTimeoutMs = 2000;
    private long defaultReadTimeoutMs = 5000;
    private String proxyUrl;
    private String proxyUsername;
    private String proxyPassword;

    // setters used by Spring config-property binding
    public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; }
    public void setTrustedCaPemPaths(List<String> paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); }
    public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; }
    public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; }
    public void setProxyUrl(String v) { this.proxyUrl = v; }
    public void setProxyUsername(String v) { this.proxyUsername = v; }
    public void setProxyPassword(String v) { this.proxyPassword = v; }

    @PostConstruct
    void validate() {
        if (trustAll) {
            log.warn("⚠ cameleer.server.outbound-http.trust-all is ON — all outbound HTTPS cert validation is DISABLED. Do not use in production.");
        }
        for (String p : trustedCaPemPaths) {
            if (!Files.exists(Path.of(p))) {
                throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p);
            }
            log.info("Outbound HTTP: trusting additional CA from {}", p);
        }
    }

    @Bean
    public OutboundHttpProperties outboundHttpProperties() {
        return new OutboundHttpProperties(
                trustAll, trustedCaPemPaths,
                Duration.ofMillis(defaultConnectTimeoutMs),
                Duration.ofMillis(defaultReadTimeoutMs),
                proxyUrl, proxyUsername, proxyPassword);
    }

    @Bean
    public SslContextBuilder sslContextBuilder() { return new SslContextBuilder(); }

    @Bean
    public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) {
        return new ApacheOutboundHttpClientFactory(props, builder);
    }
}
```

- [ ] **Step 5: Add defaults to `application.yml`**

Edit `cameleer-server-app/src/main/resources/application.yml`, adding under `cameleer.server`:

```yaml
cameleer:
  server:
    outbound-http:
      trust-all: false
      trusted-ca-pem-paths: []
      default-connect-timeout-ms: 2000
      default-read-timeout-ms: 5000
      # proxy-url:
      # proxy-username:
      # proxy-password:
```

- [ ] **Step 6: Run test, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=ApacheOutboundHttpClientFactoryIT`

- [ ] **Step 7: OIDC outbound audit note (no code change)**

Open `OidcProviderHelper.java` and `OidcTokenExchanger.java` (under `cameleer-server-app/src/main/java/com/cameleer/server/app/security/`). Read through to determine what TLS trust mechanism OIDC currently uses (likely default JDK + Nimbus defaults). Record findings in a comment in the Task 4 commit message, e.g.:

> "OIDC audit: currently uses Nimbus JOSE default HTTP client, which uses JDK default trust. Alerting's OutboundHttpClientFactory is the new one-true-way; OIDC retrofit is a separate follow-up (documented in alerting spec §20)."

Do NOT modify OIDC code in this commit. OIDC retrofit is explicitly deferred.

- [ ] **Step 8: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java \
        cameleer-server-app/src/main/resources/application.yml \
        cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java
git commit -m "feat(http): ApacheOutboundHttpClientFactory with memoization and startup validation

OIDC audit: currently uses Nimbus JOSE default HTTP client (JDK default trust).
Retrofit to OutboundHttpClientFactory deferred to a separate follow-up."
```

---

## Phase 4 — Outbound connections domain (`core/outbound/`)

### Task 5: Core records and interfaces

**Files:**
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java`

- [ ] **Step 1: `OutboundMethod` + `OutboundAuthKind`**

```java
package com.cameleer.server.core.outbound;

public enum OutboundMethod { POST, PUT, PATCH }
```

```java
package com.cameleer.server.core.outbound;

public enum OutboundAuthKind { NONE, BEARER, BASIC }
```

- [ ] **Step 2: `OutboundAuth` sealed interface**

```java
package com.cameleer.server.core.outbound;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
    @JsonSubTypes.Type(OutboundAuth.None.class),
    @JsonSubTypes.Type(OutboundAuth.Bearer.class),
    @JsonSubTypes.Type(OutboundAuth.Basic.class),
})
public sealed interface OutboundAuth permits OutboundAuth.None, OutboundAuth.Bearer, OutboundAuth.Basic {
    OutboundAuthKind kind();

    record None() implements OutboundAuth {
        public OutboundAuthKind kind() { return OutboundAuthKind.NONE; }
    }
    record Bearer(String tokenCiphertext) implements OutboundAuth {
        public OutboundAuthKind kind() { return OutboundAuthKind.BEARER; }
    }
    record Basic(String username, String passwordCiphertext) implements OutboundAuth {
        public OutboundAuthKind kind() { return OutboundAuthKind.BASIC; }
    }
}
```

- [ ] **Step 3: `OutboundConnection` record**

```java
package com.cameleer.server.core.outbound;

import com.cameleer.server.core.http.TrustMode;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public record OutboundConnection(
        UUID id,
        String tenantId,
        String name,
        String description,
        String url,
        OutboundMethod method,
        Map<String, String> defaultHeaders,
        String defaultBodyTmpl,
        TrustMode tlsTrustMode,
        List<String> tlsCaPemPaths,
        String hmacSecretCiphertext,          // encrypted via SecretCipher
        OutboundAuth auth,
        List<UUID> allowedEnvironmentIds,     // empty = allowed in all envs
        Instant createdAt,
        UUID createdBy,
        Instant updatedAt,
        UUID updatedBy
) {
    public OutboundConnection {
        defaultHeaders = defaultHeaders == null ? Map.of() : Map.copyOf(defaultHeaders);
        tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : List.copyOf(tlsCaPemPaths);
        allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : List.copyOf(allowedEnvironmentIds);
        if (auth == null) auth = new OutboundAuth.None();
    }

    public boolean isAllowedInEnvironment(UUID environmentId) {
        return allowedEnvironmentIds.isEmpty() || allowedEnvironmentIds.contains(environmentId);
    }
}
```

- [ ] **Step 4: `OutboundConnectionRepository` interface**

```java
package com.cameleer.server.core.outbound;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface OutboundConnectionRepository {
    OutboundConnection save(OutboundConnection connection);   // insert on null id slot, update otherwise
    Optional<OutboundConnection> findById(String tenantId, UUID id);
    Optional<OutboundConnection> findByName(String tenantId, String name);
    List<OutboundConnection> listByTenant(String tenantId);
    void delete(String tenantId, UUID id);
}
```

- [ ] **Step 5: `OutboundConnectionService` interface**

```java
package com.cameleer.server.core.outbound;

import java.util.List;
import java.util.UUID;

public interface OutboundConnectionService {
    OutboundConnection create(OutboundConnection draft, UUID actingUserId);
    OutboundConnection update(UUID id, OutboundConnection draft, UUID actingUserId);
    void delete(UUID id, UUID actingUserId);
    OutboundConnection get(UUID id);
    List<OutboundConnection> list();
    List<UUID> rulesReferencing(UUID id);   // Plan 01 stub returns empty; Plan 02 wires to alert-rule repo
}
```

- [ ] **Step 6: Compile + commit**

Run: `mvn -pl cameleer-server-core compile`

```bash
git add cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/
git commit -m "feat(outbound): core domain records for outbound connections"
```

---

### Task 6: `SecretCipher` — bespoke symmetric encryption over Ed25519-derived key

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java`

This resolves spec §20 item 2: bespoke cipher, no new dep. Uses AES-GCM with a key derived via HMAC-SHA256 from the existing JWT secret (same key derivation shape as `Ed25519SigningService`).

- [ ] **Step 1: Write the failing test**

```java
package com.cameleer.server.app.outbound.crypto;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class SecretCipherTest {
    private final SecretCipher cipher = new SecretCipher("test-jwt-secret-must-be-long-enough-to-derive");

    @Test
    void roundTrips() {
        String plaintext = "my-hmac-secret-12345";
        String ct = cipher.encrypt(plaintext);
        assertThat(ct).isNotEqualTo(plaintext);
        assertThat(cipher.decrypt(ct)).isEqualTo(plaintext);
    }

    @Test
    void differentCiphertextsForSamePlaintext() {
        String a = cipher.encrypt("x");
        String b = cipher.encrypt("x");
        assertThat(a).isNotEqualTo(b); // fresh IV per call
        assertThat(cipher.decrypt(a)).isEqualTo(cipher.decrypt(b));
    }

    @Test
    void decryptRejectsTamperedCiphertext() {
        String ct = cipher.encrypt("abc");
        String tampered = ct.substring(0, ct.length() - 2) + "00";
        assertThatThrownBy(() -> cipher.decrypt(tampered))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void decryptRejectsWrongKey() {
        String ct = cipher.encrypt("abc");
        SecretCipher other = new SecretCipher("some-other-jwt-secret-that-is-long-enough");
        assertThatThrownBy(() -> other.decrypt(ct))
                .isInstanceOf(IllegalArgumentException.class);
    }
}
```

- [ ] **Step 2: Run test, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=SecretCipherTest`

- [ ] **Step 3: Implement `SecretCipher`**

```java
package com.cameleer.server.app.outbound.crypto;

import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;

/**
 * Bespoke symmetric cipher for small secrets stored at rest.
 * Key = HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1").
 * Ciphertext = base64( IV(12) || tag+ciphertext )  — AES-GCM/NoPadding, 128-bit auth tag.
 */
public class SecretCipher {
    private static final String KDF_LABEL = "cameleer-outbound-secret-v1";
    private static final int IV_BYTES = 12;
    private static final int TAG_BITS = 128;
    private final SecretKeySpec aesKey;
    private final SecureRandom random = new SecureRandom();

    public SecretCipher(String jwtSecret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] keyBytes = mac.doFinal(KDF_LABEL.getBytes(StandardCharsets.UTF_8));
            this.aesKey = new SecretKeySpec(keyBytes, "AES");
        } catch (Exception e) {
            throw new IllegalStateException("Failed to derive outbound secret key", e);
        }
    }

    public String encrypt(String plaintext) {
        try {
            byte[] iv = new byte[IV_BYTES];
            random.nextBytes(iv);
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv));
            byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
            ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length);
            buf.put(iv).put(ct);
            return Base64.getEncoder().encodeToString(buf.array());
        } catch (Exception e) {
            throw new IllegalStateException("Encryption failed", e);
        }
    }

    public String decrypt(String ciphertextB64) {
        try {
            byte[] full = Base64.getDecoder().decode(ciphertextB64);
            if (full.length < IV_BYTES + 16) throw new IllegalArgumentException("ciphertext too short");
            byte[] iv = new byte[IV_BYTES];
            System.arraycopy(full, 0, iv, 0, IV_BYTES);
            byte[] ct = new byte[full.length - IV_BYTES];
            System.arraycopy(full, IV_BYTES, ct, 0, ct.length);
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv));
            byte[] pt = cipher.doFinal(ct);
            return new String(pt, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new IllegalArgumentException("Decryption failed", e);
        }
    }
}
```

- [ ] **Step 4: Run test, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=SecretCipherTest`

- [ ] **Step 5: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java
git commit -m "feat(outbound): SecretCipher — AES-GCM with JWT-secret-derived key for at-rest encryption of webhook secrets"
```

---

## Phase 5 — Outbound connections persistence

### Task 7: `PostgresOutboundConnectionRepository` + integration test

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java`

- [ ] **Step 1: Write the failing integration test**

Follow the same Testcontainers pattern as `ConfigEnvIsolationIT`. Seed a `users` row for `created_by`/`updated_by` FK.

```java
package com.cameleer.server.app.outbound.storage;

import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundMethod;
// … imports for @SpringBootTest, Testcontainers, JdbcTemplate, etc.

import org.junit.jupiter.api.Test;
// ... test setup inheriting from the existing test superclass that sets up PG + Flyway
import org.springframework.beans.factory.annotation.Autowired;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT {

    @Autowired PostgresOutboundConnectionRepository repo;
    @Autowired TestFixtures fixtures;

    @Test
    void saveAndRead() {
        UUID userId = fixtures.createUser("alice@example.com");
        OutboundConnection c = new OutboundConnection(
                UUID.randomUUID(), "default", "slack-ops", "ops channel",
                "https://hooks.slack.com/services/T/B/X", OutboundMethod.POST,
                Map.of("Content-Type", "application/json"), null,
                TrustMode.SYSTEM_DEFAULT, List.of(),
                null, new OutboundAuth.None(), List.of(),
                Instant.now(), userId, Instant.now(), userId);

        repo.save(c);
        var loaded = repo.findById("default", c.id()).orElseThrow();
        assertThat(loaded.name()).isEqualTo("slack-ops");
        assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json");
    }

    @Test
    void uniqueNamePerTenant() { /* expect DataIntegrityViolationException on duplicate name */ }

    @Test
    void allowedEnvironmentIdsRoundTrip() { /* save with [env1, env2], reload, assert equality */ }

    @Test
    void listByTenantOnlyReturnsCurrentTenant() { /* save for default + other-tenant, list shows only default */ }

    @Test
    void deleteRemovesRow() { /* save then delete, findById returns empty */ }
}
```

Fill in the concrete test bodies — these stubs are the happy-path skeleton.

- [ ] **Step 2: Run test, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=PostgresOutboundConnectionRepositoryIT`

- [ ] **Step 3: Implement `PostgresOutboundConnectionRepository`**

```java
package com.cameleer.server.app.outbound.storage;

import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundAuthKind;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundMethod;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;

import java.sql.Array;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;

public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository {

    private final JdbcTemplate jdbc;
    private final ObjectMapper mapper;

    public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
        this.jdbc = jdbc;
        this.mapper = mapper;
    }

    @Override
    public OutboundConnection save(OutboundConnection c) {
        // Upsert semantics: if id exists, update; else insert.
        boolean exists = findById(c.tenantId(), c.id()).isPresent();
        if (exists) {
            jdbc.update("""
                UPDATE outbound_connections
                   SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum,
                       default_headers = ?::jsonb, default_body_tmpl = ?,
                       tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb,
                       hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum,
                       auth_config = ?::jsonb, allowed_environment_ids = ?,
                       updated_at = now(), updated_by = ?
                 WHERE tenant_id = ? AND id = ?""",
                c.name(), c.description(), c.url(), c.method().name(),
                writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
                c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
                c.hmacSecretCiphertext(), c.auth().kind().name(),
                writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()),
                c.updatedBy(), c.tenantId(), c.id());
        } else {
            jdbc.update("""
                INSERT INTO outbound_connections (
                    id, tenant_id, name, description, url, method,
                    default_headers, default_body_tmpl,
                    tls_trust_mode, tls_ca_pem_paths,
                    hmac_secret_ciphertext, auth_kind, auth_config,
                    allowed_environment_ids, created_by, updated_by)
                VALUES (?,?,?,?,?,?::outbound_method_enum,
                        ?::jsonb,?,
                        ?::trust_mode_enum,?::jsonb,
                        ?,?::outbound_auth_kind_enum,?::jsonb,
                        ?,?,?)""",
                c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(),
                writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
                c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
                c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()),
                toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy());
        }
        return findById(c.tenantId(), c.id()).orElseThrow();
    }

    @Override
    public Optional<OutboundConnection> findById(String tenantId, UUID id) {
        return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND id = ?",
                rowMapper, tenantId, id).stream().findFirst();
    }

    @Override
    public Optional<OutboundConnection> findByName(String tenantId, String name) {
        return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND name = ?",
                rowMapper, tenantId, name).stream().findFirst();
    }

    @Override
    public List<OutboundConnection> listByTenant(String tenantId) {
        return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? ORDER BY name",
                rowMapper, tenantId);
    }

    @Override
    public void delete(String tenantId, UUID id) {
        jdbc.update("DELETE FROM outbound_connections WHERE tenant_id = ? AND id = ?", tenantId, id);
    }

    private final RowMapper<OutboundConnection> rowMapper = (rs, i) -> new OutboundConnection(
            rs.getObject("id", UUID.class), rs.getString("tenant_id"),
            rs.getString("name"), rs.getString("description"),
            rs.getString("url"), OutboundMethod.valueOf(rs.getString("method")),
            readMapString(rs.getString("default_headers")), rs.getString("default_body_tmpl"),
            TrustMode.valueOf(rs.getString("tls_trust_mode")),
            readListString(rs.getString("tls_ca_pem_paths")),
            rs.getString("hmac_secret_ciphertext"),
            readAuth(OutboundAuthKind.valueOf(rs.getString("auth_kind")), rs.getString("auth_config")),
            readUuidArray(rs.getArray("allowed_environment_ids")),
            rs.getTimestamp("created_at").toInstant(), rs.getObject("created_by", UUID.class),
            rs.getTimestamp("updated_at").toInstant(), rs.getObject("updated_by", UUID.class));

    // JSON helpers — wrap Jackson
    private String writeJson(Object v) {
        try { return mapper.writeValueAsString(v); }
        catch (Exception e) { throw new IllegalStateException(e); }
    }
    private Map<String, String> readMapString(String json) {
        try { return mapper.readValue(json, new TypeReference<>() {}); }
        catch (Exception e) { throw new IllegalStateException(e); }
    }
    private List<String> readListString(String json) {
        try { return mapper.readValue(json, new TypeReference<>() {}); }
        catch (Exception e) { throw new IllegalStateException(e); }
    }
    private OutboundAuth readAuth(OutboundAuthKind kind, String cfg) {
        // Deserialize based on the kind column — decoupled from DEDUCTION at this layer.
        try {
            return switch (kind) {
                case NONE   -> new OutboundAuth.None();
                case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class);
                case BASIC  -> mapper.readValue(cfg, OutboundAuth.Basic.class);
            };
        } catch (Exception e) { throw new IllegalStateException(e); }
    }

    private Object toUuidArray(List<UUID> ids) {
        try {
            var conn = jdbc.getDataSource().getConnection();
            try { return conn.createArrayOf("uuid", ids.toArray()); } finally { conn.close(); }
        } catch (Exception e) { throw new IllegalStateException(e); }
    }
    private List<UUID> readUuidArray(Array arr) throws SQLException {
        if (arr == null) return List.of();
        Object[] raw = (Object[]) arr.getArray();
        List<UUID> out = new ArrayList<>(raw.length);
        for (Object o : raw) out.add((UUID) o);
        return out;
    }
}
```

- [ ] **Step 4: Wire the repo bean**

Create `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java`:

```java
package com.cameleer.server.app.outbound.config;

import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

@Configuration
public class OutboundBeanConfig {

    @Bean
    public OutboundConnectionRepository outboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
        return new PostgresOutboundConnectionRepository(jdbc, mapper);
    }

    @Bean
    public SecretCipher secretCipher(@Value("${cameleer.server.security.jwtsecret}") String jwtSecret) {
        return new SecretCipher(jwtSecret);
    }
}
```

(Confirm the exact property key for the JWT secret by grepping the existing codebase — look in `SecurityBeanConfig` or `JwtServiceImpl` for the `@Value` annotation.)

- [ ] **Step 5: Run test, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=PostgresOutboundConnectionRepositoryIT`

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/ \
        cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/ \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/
git commit -m "feat(outbound): Postgres repository for outbound_connections"
```

---

### Task 8: `OutboundConnectionServiceImpl` with allowed-env + referenced-by-rules checks

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java`
- Modify: `OutboundBeanConfig.java` (register service bean)

Service owns business rules: secret encryption on save, deletion 409 if referenced, narrow-allowed-envs 409 if referenced in removed env.

- [ ] **Step 1: Implement the service**

```java
package com.cameleer.server.app.outbound;

import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

public class OutboundConnectionServiceImpl implements OutboundConnectionService {

    private final OutboundConnectionRepository repo;
    private final SecretCipher cipher;
    private final String tenantId;

    public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, SecretCipher cipher, String tenantId) {
        this.repo = repo;
        this.cipher = cipher;
        this.tenantId = tenantId;
    }

    @Override
    public OutboundConnection create(OutboundConnection draft, UUID actingUserId) {
        assertNameUnique(draft.name(), null);
        OutboundConnection c = new OutboundConnection(
                UUID.randomUUID(), tenantId, draft.name(), draft.description(),
                draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
                draft.tlsTrustMode(), draft.tlsCaPemPaths(),
                draft.hmacSecretCiphertext(),            // controller layer calls encrypt() before handing to service
                draft.auth(), draft.allowedEnvironmentIds(),
                Instant.now(), actingUserId, Instant.now(), actingUserId);
        return repo.save(c);
    }

    @Override
    public OutboundConnection update(UUID id, OutboundConnection draft, UUID actingUserId) {
        OutboundConnection existing = repo.findById(tenantId, id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        if (!existing.name().equals(draft.name())) assertNameUnique(draft.name(), id);

        // Narrowing allowed-envs guard: any rule currently referencing this in a removed env?
        List<UUID> removed = existing.allowedEnvironmentIds().stream()
                .filter(e -> !existing.allowedEnvironmentIds().isEmpty())
                .filter(e -> !draft.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().contains(e))
                .toList();
        if (!removed.isEmpty()) {
            List<UUID> refs = rulesReferencing(id); // Plan 01 stub — returns empty; Plan 02 implements
            if (!refs.isEmpty()) {
                throw new ResponseStatusException(HttpStatus.CONFLICT,
                    "Narrowing allowed environments while rules still reference this connection in removed envs: " + refs);
            }
        }

        OutboundConnection updated = new OutboundConnection(
                id, tenantId, draft.name(), draft.description(), draft.url(), draft.method(),
                draft.defaultHeaders(), draft.defaultBodyTmpl(),
                draft.tlsTrustMode(), draft.tlsCaPemPaths(),
                // If the request comes with a null hmacSecretCiphertext, retain existing (zero-knowledge rotation requires a separate endpoint later).
                draft.hmacSecretCiphertext() != null ? draft.hmacSecretCiphertext() : existing.hmacSecretCiphertext(),
                draft.auth(), draft.allowedEnvironmentIds(),
                existing.createdAt(), existing.createdBy(), Instant.now(), actingUserId);
        return repo.save(updated);
    }

    @Override
    public void delete(UUID id, UUID actingUserId) {
        List<UUID> refs = rulesReferencing(id); // Plan 01 stub
        if (!refs.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.CONFLICT,
                "Outbound connection is referenced by rules: " + refs);
        }
        repo.delete(tenantId, id);
    }

    @Override public OutboundConnection get(UUID id) {
        return repo.findById(tenantId, id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @Override public List<OutboundConnection> list() { return repo.listByTenant(tenantId); }

    @Override
    public List<UUID> rulesReferencing(UUID id) {
        // Plan 01 stub. Plan 02 will wire this to AlertRuleRepository.
        return List.of();
    }

    private void assertNameUnique(String name, UUID excludingId) {
        repo.findByName(tenantId, name).ifPresent(c -> {
            if (!c.id().equals(excludingId)) {
                throw new ResponseStatusException(HttpStatus.CONFLICT, "Connection name already exists: " + name);
            }
        });
    }
}
```

- [ ] **Step 2: Register the service bean**

In `OutboundBeanConfig.java`, add:

```java
@Bean
public OutboundConnectionService outboundConnectionService(
        OutboundConnectionRepository repo, SecretCipher cipher,
        @Value("${cameleer.server.tenant-id:default}") String tenantId) {
    return new OutboundConnectionServiceImpl(repo, cipher, tenantId);
}
```

Confirm the exact property key for tenant id by grepping the codebase (`CAMELEER_SERVER_TENANT_ID` per CLAUDE.md → check the Spring property mapping, likely `cameleer.server.tenantid` or `cameleer.server.tenant-id`).

- [ ] **Step 3: Compile**

Run: `mvn -pl cameleer-server-app compile`

- [ ] **Step 4: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java
git commit -m "feat(outbound): service with uniqueness + narrow-envs + delete-if-referenced guards (rules check stubbed; wired in Plan 02)"
```

---

## Phase 6 — Admin REST API

### Task 9: DTOs + Bean Validation unit tests

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java`

- [ ] **Step 1: Write validation test (failing)**

```java
package com.cameleer.server.app.outbound;

import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundMethod;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

class OutboundConnectionRequestValidationTest {
    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validRequestPasses() {
        var r = new OutboundConnectionRequest("slack-ops", "desc",
                "https://hooks.slack.com/x", OutboundMethod.POST,
                Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
                null, new OutboundAuth.None(), List.of());
        assertThat(validator.validate(r)).isEmpty();
    }

    @Test
    void blankNameFails() {
        var r = new OutboundConnectionRequest("  ", null, "https://x", OutboundMethod.POST,
                Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of());
        assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("name"));
    }

    @Test
    void nonHttpsUrlFails() {
        var r = new OutboundConnectionRequest("n", null, "http://x", OutboundMethod.POST,
                Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of());
        assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("url"));
    }

    @Test
    void trustPathsRequiresNonEmptyCaList() {
        var r = new OutboundConnectionRequest("n", null, "https://x", OutboundMethod.POST,
                Map.of(), null, TrustMode.TRUST_PATHS, List.of(), null, new OutboundAuth.None(), List.of());
        assertThat(validator.validate(r)).isNotEmpty();
    }
}
```

- [ ] **Step 2: Run, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionRequestValidationTest`

- [ ] **Step 3: Implement the DTOs**

```java
package com.cameleer.server.app.outbound.dto;

import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundMethod;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import java.util.List;
import java.util.Map;
import java.util.UUID;

public record OutboundConnectionRequest(
        @NotBlank @Size(max = 100) String name,
        @Size(max = 2000) String description,
        @NotBlank @Pattern(regexp = "^https://.+", message = "URL must be HTTPS") String url,
        @NotNull OutboundMethod method,
        Map<String, String> defaultHeaders,
        String defaultBodyTmpl,
        @NotNull TrustMode tlsTrustMode,
        List<String> tlsCaPemPaths,
        String hmacSecret,            // plaintext in request; service encrypts before persist
        @NotNull @Valid OutboundAuth auth,
        List<UUID> allowedEnvironmentIds
) {
    public OutboundConnectionRequest {
        defaultHeaders = defaultHeaders == null ? Map.of() : defaultHeaders;
        tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : tlsCaPemPaths;
        allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : allowedEnvironmentIds;

        // Cross-field: TRUST_PATHS requires non-empty caPemPaths
        if (tlsTrustMode == TrustMode.TRUST_PATHS && (tlsCaPemPaths == null || tlsCaPemPaths.isEmpty())) {
            throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS");
        }
    }
}
```

```java
package com.cameleer.server.app.outbound.dto;

import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuthKind;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundMethod;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public record OutboundConnectionDto(
        UUID id, String name, String description, String url,
        OutboundMethod method, Map<String, String> defaultHeaders, String defaultBodyTmpl,
        TrustMode tlsTrustMode, List<String> tlsCaPemPaths,
        boolean hmacSecretSet,                     // never return the actual secret
        OutboundAuthKind authKind,                  // summarized; full auth cfg never returned
        List<UUID> allowedEnvironmentIds,
        Instant createdAt, UUID createdBy, Instant updatedAt, UUID updatedBy
) {
    public static OutboundConnectionDto from(OutboundConnection c) {
        return new OutboundConnectionDto(
                c.id(), c.name(), c.description(), c.url(), c.method(),
                c.defaultHeaders(), c.defaultBodyTmpl(),
                c.tlsTrustMode(), c.tlsCaPemPaths(),
                c.hmacSecretCiphertext() != null,
                c.auth().kind(), c.allowedEnvironmentIds(),
                c.createdAt(), c.createdBy(), c.updatedAt(), c.updatedBy());
    }
}
```

```java
package com.cameleer.server.app.outbound.dto;

public record OutboundConnectionTestResult(
        int status,
        long latencyMs,
        String responseSnippet,           // first 512 chars
        String tlsProtocol,               // e.g., "TLSv1.3"
        String tlsCipherSuite,
        String peerCertificateSubject,
        Long peerCertificateExpiresAtEpochMs,
        String error                      // null on success, message on failure
) {}
```

- [ ] **Step 4: Run, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionRequestValidationTest`

- [ ] **Step 5: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/ \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java
git commit -m "feat(outbound): request + response + test-result DTOs with Bean Validation"
```

### Task 10: Controller + integration test

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`

- [ ] **Step 1: Add audit categories**

Edit `AuditCategory.java` — add `OUTBOUND_CONNECTION_CHANGE` and `OUTBOUND_HTTP_TRUST_CHANGE` to the enum.

- [ ] **Step 2: Write MockMvc integration test (failing)**

```java
package com.cameleer.server.app.outbound.controller;

// imports: @SpringBootTest, MockMvc, WithMockUser, etc.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class OutboundConnectionAdminControllerIT extends AbstractAdminControllerIT {

    @Test void adminCanCreate() throws Exception {
        String body = """
            {"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST",
             "tlsTrustMode":"SYSTEM_DEFAULT","auth":{"kind":"NONE"}}""";
        mockMvc.perform(post("/api/v1/admin/outbound-connections")
                .with(adminAuth())
                .contentType(MediaType.APPLICATION_JSON).content(body))
               .andExpect(status().isCreated())
               .andExpect(jsonPath("$.name").value("slack-ops"))
               .andExpect(jsonPath("$.hmacSecretSet").value(false));
    }

    @Test void operatorCannotCreate() throws Exception {
        mockMvc.perform(post("/api/v1/admin/outbound-connections")
                .with(operatorAuth()).contentType(MediaType.APPLICATION_JSON).content("{}"))
               .andExpect(status().isForbidden());
    }

    @Test void operatorCanList() throws Exception {
        mockMvc.perform(get("/api/v1/admin/outbound-connections").with(operatorAuth()))
               .andExpect(status().isOk());
    }

    @Test void viewerCannotList() throws Exception {
        mockMvc.perform(get("/api/v1/admin/outbound-connections").with(viewerAuth()))
               .andExpect(status().isForbidden());
    }

    @Test void nonHttpsUrlRejected() throws Exception {
        String body = """
            {"name":"x","url":"http://x","method":"POST","tlsTrustMode":"SYSTEM_DEFAULT","auth":{"kind":"NONE"}}""";
        mockMvc.perform(post("/api/v1/admin/outbound-connections")
                .with(adminAuth()).contentType(MediaType.APPLICATION_JSON).content(body))
               .andExpect(status().isBadRequest());
    }

    @Test void duplicateNameReturns409() throws Exception { /* create twice with same name */ }
    @Test void deleteRemoves() throws Exception { /* create, delete, get 404 */ }
}
```

- [ ] **Step 3: Run, expect failure**

Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionAdminControllerIT`

- [ ] **Step 4: Implement the controller**

```java
package com.cameleer.server.app.outbound.controller;

import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.outbound.dto.OutboundConnectionDto;
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/v1/admin/outbound-connections")
@Tag(name = "Outbound Connections Admin", description = "Admin-managed outbound HTTPS destinations")
public class OutboundConnectionAdminController {

    private final OutboundConnectionService service;
    private final SecretCipher cipher;
    private final AuditService audit;

    public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, AuditService audit) {
        this.service = service;
        this.cipher = cipher;
        this.audit = audit;
    }

    @GetMapping
    @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
    public List<OutboundConnectionDto> list() {
        return service.list().stream().map(OutboundConnectionDto::from).toList();
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
    public OutboundConnectionDto get(@PathVariable UUID id) {
        return OutboundConnectionDto.from(service.get(id));
    }

    @PostMapping
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<OutboundConnectionDto> create(@Valid @RequestBody OutboundConnectionRequest req,
                                                        @AuthenticationPrincipal UserPrincipal user,
                                                        HttpServletRequest http) {
        OutboundConnection draft = toDraft(req, null, Instant.now(), user.id(), Instant.now(), user.id());
        OutboundConnection saved = service.create(draft, user.id());
        audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
                saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, http);
        return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved));
    }

    @PutMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req,
                                         @AuthenticationPrincipal UserPrincipal user, HttpServletRequest http) {
        OutboundConnection existing = service.get(id);
        OutboundConnection draft = toDraft(req, existing.id(), existing.createdAt(), existing.createdBy(),
                                            Instant.now(), user.id());
        OutboundConnection saved = service.update(id, draft, user.id());
        audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
                id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, http);
        return OutboundConnectionDto.from(saved);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> delete(@PathVariable UUID id,
                                        @AuthenticationPrincipal UserPrincipal user, HttpServletRequest http) {
        service.delete(id, user.id());
        audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
                id.toString(), Map.of(), AuditResult.SUCCESS, http);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/{id}/usage")
    @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
    public List<UUID> usage(@PathVariable UUID id) {
        return service.rulesReferencing(id);   // empty in Plan 01
    }

    private OutboundConnection toDraft(OutboundConnectionRequest req, UUID id,
                                         Instant createdAt, UUID createdBy,
                                         Instant updatedAt, UUID updatedBy) {
        String cipherSecret = req.hmacSecret() == null || req.hmacSecret().isBlank()
                ? null : cipher.encrypt(req.hmacSecret());
        return new OutboundConnection(
                id, null /* tenant filled by service */, req.name(), req.description(),
                req.url(), req.method(), req.defaultHeaders(), req.defaultBodyTmpl(),
                req.tlsTrustMode(), req.tlsCaPemPaths(),
                cipherSecret, req.auth(), req.allowedEnvironmentIds(),
                createdAt, createdBy, updatedAt, updatedBy);
    }
}
```

Confirm the `@AuthenticationPrincipal UserPrincipal` shape from existing controllers (e.g., `UserAdminController`). If the project uses a different principal type, swap accordingly.

- [ ] **Step 5: Update `SecurityConfig.java`**

Add the path pattern `/api/v1/admin/outbound-connections/**` to the admin-authenticated rules (or wherever other admin paths are matched). Allow both `ROLE_ADMIN` and `ROLE_OPERATOR` to hit the list/get; controller-level `@PreAuthorize` is the authoritative gate.

- [ ] **Step 6: Run, expect PASS**

Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionAdminControllerIT`

- [ ] **Step 7: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/ \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/ \
        cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
git commit -m "feat(outbound): admin CRUD REST + RBAC + audit

New audit categories: OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE."
```

### Task 11: `POST /{id}/test` — probe endpoint with TLS summary

**Files:**
- Modify: `OutboundConnectionAdminController.java` — add test endpoint
- Modify: `OutboundConnectionAdminControllerIT.java` — test against WireMock

The test endpoint issues a synthetic probe (HEAD or minimal POST with an empty body) against the connection's URL, returning status + latency + TLS summary. Useful for "why doesn't my webhook work."

- [ ] **Step 1: Add test method (fail-first)**

```java
@Test
void testActionReturnsStatusAndTlsSummary() throws Exception {
    // stubbed WireMock endpoint at /probe returns 200
    // create a connection pointing at it with TRUST_ALL
    // POST /{id}/test — expect 200 body with status=200, tlsProtocol populated
}
```

Run and confirm failure.

- [ ] **Step 2: Add endpoint to controller**

```java
@PostMapping("/{id}/test")
@PreAuthorize("hasRole('ADMIN')")
public OutboundConnectionTestResult test(@PathVariable UUID id,
                                          @AuthenticationPrincipal UserPrincipal user,
                                          HttpServletRequest http) {
    OutboundConnection c = service.get(id);
    long t0 = System.currentTimeMillis();
    try {
        var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null);
        var client = httpClientFactory.clientFor(ctx);
        var request = new HttpPost(c.url());
        // Minimal safe probe body — empty or {} — we don't care about the response shape
        request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON));
        try (var resp = client.execute(request)) {
            long latency = System.currentTimeMillis() - t0;
            var entity = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
            String snippet = entity == null ? "" : entity.substring(0, Math.min(512, entity.length()));
            // TLS details — extract from the connection if possible (HttpClient 5 exposes via response context on newer versions; if not, stub with a conservative value)
            return new OutboundConnectionTestResult(resp.getCode(), latency, snippet,
                    "TLS", null, null, null, null);
        }
    } catch (Exception e) {
        long latency = System.currentTimeMillis() - t0;
        return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage());
    }
}
```

Inject `OutboundHttpClientFactory` in the controller constructor.

- [ ] **Step 3: Run, expect PASS**

- [ ] **Step 4: Commit**

```bash
git commit -am "feat(outbound): admin test action for reachability + TLS summary"
```

### Task 12: OpenAPI regeneration

- [ ] **Step 1: Start backend on :8081**

- [ ] **Step 2: Regenerate UI types**

```bash
cd ui && npm run generate-api:live
```

- [ ] **Step 3: Verify `ui/src/api/schema.d.ts` diff contains the new endpoints**

```bash
git diff ui/src/api/schema.d.ts | head -60
```

- [ ] **Step 4: Commit**

```bash
git add ui/src/api/schema.d.ts ui/src/api/openapi.json
git commit -m "chore(ui): regenerate OpenAPI types for outbound connections"
```

---

## Phase 7 — Admin UI

### Task 13: Query hooks

**Files:**
- Create: `ui/src/api/queries/admin/outboundConnections.ts`

- [ ] **Step 1: Follow the existing pattern**

Study `ui/src/api/queries/admin/thresholds.ts` — it's the closest analog (simple admin CRUD with tanstack).

- [ ] **Step 2: Implement**

```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
import type { components } from '../../schema';

export type OutboundConnection = components['schemas']['OutboundConnectionDto'];
export type OutboundConnectionRequest = components['schemas']['OutboundConnectionRequest'];
export type OutboundConnectionTestResult = components['schemas']['OutboundConnectionTestResult'];

export function useOutboundConnections() {
  return useQuery({
    queryKey: ['admin', 'outbound-connections'],
    queryFn: () => adminFetch<OutboundConnection[]>('/outbound-connections'),
  });
}

export function useOutboundConnection(id: string | undefined) {
  return useQuery({
    queryKey: ['admin', 'outbound-connections', id],
    queryFn: () => adminFetch<OutboundConnection>(`/outbound-connections/${id}`),
    enabled: !!id,
  });
}

export function useCreateOutboundConnection() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (req: OutboundConnectionRequest) =>
      adminFetch<OutboundConnection>('/outbound-connections', { method: 'POST', body: JSON.stringify(req) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }),
  });
}

export function useUpdateOutboundConnection(id: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (req: OutboundConnectionRequest) =>
      adminFetch<OutboundConnection>(`/outbound-connections/${id}`, { method: 'PUT', body: JSON.stringify(req) }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] });
      qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections', id] });
    },
  });
}

export function useDeleteOutboundConnection() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: string) =>
      adminFetch<void>(`/outbound-connections/${id}`, { method: 'DELETE' }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }),
  });
}

export function useTestOutboundConnection() {
  return useMutation({
    mutationFn: (id: string) =>
      adminFetch<OutboundConnectionTestResult>(`/outbound-connections/${id}/test`, { method: 'POST' }),
  });
}
```

- [ ] **Step 3: TypeScript check**

Run: `cd ui && npm run typecheck` — expect clean.

- [ ] **Step 4: Commit**

```bash
git add ui/src/api/queries/admin/outboundConnections.ts
git commit -m "feat(ui): tanstack query hooks for outbound connections"
```

### Task 14: `OutboundConnectionsPage` — list view

**Files:**
- Create: `ui/src/pages/Admin/OutboundConnectionsPage.tsx`
- Create: `ui/src/pages/Admin/OutboundConnectionsPage.test.tsx`

- [ ] **Step 1: Component test first**

```tsx
import { render, screen } from '@testing-library/react';
// wrap with QueryClientProvider + MemoryRouter helpers as elsewhere in the project
// mock useOutboundConnections to return a small fixture array
// assert: table shows connection names; "New connection" button present
```

- [ ] **Step 2: Run, expect failure**

- [ ] **Step 3: Implement the page**

Follow `SensitiveKeysPage.tsx` or `ClaimMappingRulesModal.tsx` for styling patterns. Reuse `@cameleer/design-system` components (`Button`, `Label`, `Badge`).

```tsx
import { Link } from 'react-router-dom';
import { Button, Badge } from '@cameleer/design-system';
import { useOutboundConnections, useDeleteOutboundConnection } from '../../api/queries/admin/outboundConnections';
import { PageLoader } from '../../components/PageLoader';

export function OutboundConnectionsPage() {
  const { data, isLoading, error } = useOutboundConnections();
  const deleteMut = useDeleteOutboundConnection();

  if (isLoading) return <PageLoader />;
  if (error) return <div>Failed to load: {String(error)}</div>;

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <h2>Outbound Connections</h2>
        <Link to="/admin/outbound-connections/new">
          <Button variant="primary">New connection</Button>
        </Link>
      </header>
      <table>
        <thead>
          <tr><th>Name</th><th>URL</th><th>Method</th><th>Trust</th><th>Auth</th><th>Envs</th><th></th></tr>
        </thead>
        <tbody>
          {(data ?? []).map(c => (
            <tr key={c.id}>
              <td><Link to={`/admin/outbound-connections/${c.id}`}>{c.name}</Link></td>
              <td><code>{new URL(c.url).host}</code></td>
              <td>{c.method}</td>
              <td><TrustBadge mode={c.tlsTrustMode} /></td>
              <td>{c.authKind}</td>
              <td>{c.allowedEnvironmentIds?.length ? c.allowedEnvironmentIds.length : 'all'}</td>
              <td>
                <Button variant="secondary" onClick={() => {
                  if (confirm(`Delete ${c.name}?`)) deleteMut.mutate(c.id!);
                }}>Delete</Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function TrustBadge({ mode }: { mode: string }) {
  if (mode === 'TRUST_ALL') return <Badge variant="error">Trust all</Badge>;
  if (mode === 'TRUST_PATHS') return <Badge variant="warning">Custom CA</Badge>;
  return <Badge variant="default">System</Badge>;
}
```

- [ ] **Step 4: Add route entry**

Edit `ui/src/router.tsx` — add routes for `/admin/outbound-connections` and `/admin/outbound-connections/:id` and `/admin/outbound-connections/new`.

- [ ] **Step 5: Add admin navigation entry**

Edit `ui/src/pages/Admin/AdminLayout.tsx` — add "Outbound Connections" to the admin sidebar.

- [ ] **Step 6: Run tests, expect PASS**

Run: `cd ui && npm test -- OutboundConnectionsPage`

- [ ] **Step 7: Commit**

```bash
git add ui/src/pages/Admin/OutboundConnectionsPage.tsx \
        ui/src/pages/Admin/OutboundConnectionsPage.test.tsx \
        ui/src/router.tsx ui/src/pages/Admin/AdminLayout.tsx
git commit -m "feat(ui): admin page for outbound connections list + navigation"
```

### Task 15: `OutboundConnectionEditor` — create/edit form

**Files:**
- Create: `ui/src/pages/Admin/OutboundConnectionEditor.tsx`
- Create: `ui/src/pages/Admin/OutboundConnectionEditor.test.tsx`

- [ ] **Step 1: Component tests**

Cover:
- Create form renders all fields
- Submit calls the mutation with the correct payload shape
- TLS mode dropdown: selecting "Trust all" shows the amber warning
- Test button disabled until the connection is saved (has an id)
- Allowed environments multi-select toggles "allow all" when empty

- [ ] **Step 2: Run, expect failures**

- [ ] **Step 3: Implement**

```tsx
import { useParams, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { Button, Select, Label, Toggle, Badge } from '@cameleer/design-system';
import {
  useOutboundConnection, useCreateOutboundConnection, useUpdateOutboundConnection, useTestOutboundConnection
} from '../../api/queries/admin/outboundConnections';
import { useEnvironments } from '../../api/queries/admin/environments'; // use existing hook
import { PageLoader } from '../../components/PageLoader';

export function OutboundConnectionEditor() {
  const { id } = useParams();
  const isNew = !id;
  const navigate = useNavigate();
  const { data: existing, isLoading } = useOutboundConnection(isNew ? undefined : id);
  const { data: environments } = useEnvironments();
  const createMut = useCreateOutboundConnection();
  const updateMut = id ? useUpdateOutboundConnection(id) : null;
  const testMut = useTestOutboundConnection();

  const [form, setForm] = useState(() => defaultForm(existing));
  // (keep state in sync with loaded existing when it arrives)

  if (!isNew && isLoading) return <PageLoader />;

  const onSubmit = () => {
    const payload = toRequest(form);
    if (isNew) createMut.mutate(payload, { onSuccess: () => navigate('/admin/outbound-connections') });
    else updateMut!.mutate(payload);
  };

  // render fields: name, description, url, method, defaultHeaders (key/value rows),
  // defaultBodyTmpl (textarea), trust mode dropdown + CA multi-select (when TRUST_PATHS),
  // hmac secret (password input with generate-random + show/hide), auth kind dropdown,
  // allowed environments ("Allow all" checkbox that when unchecked shows a multi-select)
  // plus a Test button that calls testMut and shows the result inline.

  return (/* ... full JSX ... */ null);
}

// Helpers: defaultForm(existing?), toRequest(form) — straightforward mapping
```

Flesh out the full component. Keep styles simple; reuse design-system primitives.

- [ ] **Step 4: Run tests, expect PASS**

Run: `cd ui && npm test -- OutboundConnectionEditor`

- [ ] **Step 5: Manual browser smoke test**

Start the backend + UI dev server. Go to `/admin/outbound-connections/new`. Create a connection pointing at `https://httpbin.org/post`. Save. Click *Test*. Expect a 200 response with latency. Create another with trust mode TRUST_ALL — confirm the amber warning shows. Delete both.

- [ ] **Step 6: Commit**

```bash
git add ui/src/pages/Admin/OutboundConnectionEditor.tsx \
        ui/src/pages/Admin/OutboundConnectionEditor.test.tsx
git commit -m "feat(ui): outbound connection editor with TLS config, test action, allowed-envs multi-select"
```

---

## Phase 8 — Docs and wrap-up

### Task 16: Update rule files

**Files:**
- Modify: `.claude/rules/core-classes.md`
- Modify: `.claude/rules/app-classes.md`

- [ ] **Step 1: Read both rule files end-to-end** to understand structure.

- [ ] **Step 2: In `core-classes.md`** add two new sections:

```markdown
## http/ — Outbound HTTP primitives (cross-cutting)

- `OutboundHttpClientFactory` — interface: `clientFor(context)` returns memoized `CloseableHttpClient`
- `OutboundHttpProperties` — record: `trustAll, trustedCaPemPaths, defaultConnectTimeout, defaultReadTimeout, proxy*`
- `OutboundHttpRequestContext` — record of per-call TLS/timeout overrides
- `TrustMode` — enum: `SYSTEM_DEFAULT | TRUST_ALL | TRUST_PATHS`

## outbound/ — Admin-managed outbound connections

- `OutboundConnection` — record: id, tenantId, name, url, method, default headers/body, TLS config, HMAC secret ciphertext, auth, allowedEnvironmentIds
- `OutboundAuth` (sealed) — `None | Bearer | Basic`
- `OutboundAuthKind`, `OutboundMethod` — enums
- `OutboundConnectionRepository` — CRUD by (tenant, id)
- `OutboundConnectionService` — create/update/delete/list with uniqueness + allowed-env narrow guard + delete-if-referenced check (rules check stubbed; populated in Plan 02)
```

- [ ] **Step 3: In `app-classes.md`** add to the admin-endpoints table:

```markdown
- `OutboundConnectionAdminController` — `/api/v1/admin/outbound-connections`. GET list / POST create / GET {id} / PUT {id} / DELETE {id} / POST {id}/test / GET {id}/usage. RBAC: list/get ADMIN|OPERATOR; mutations ADMIN.
```

Add `http/` and `outbound/` implementation class entries (`ApacheOutboundHttpClientFactory`, `SslContextBuilder`, `SecretCipher`, `PostgresOutboundConnectionRepository`, `OutboundConnectionServiceImpl`).

- [ ] **Step 4: Commit**

```bash
git add .claude/rules/core-classes.md .claude/rules/app-classes.md
git commit -m "docs(rules): document http/ and outbound/ packages + admin controller"
```

### Task 17: Admin guide

**Files:**
- Create: `docs/outbound-connections.md`

- [ ] **Step 1: Write the guide**

Cover: what outbound connections are; how to create one; the TLS trust modes and when to pick each; testing a connection; allowed-environments restriction; relationship to future webhooks (forward reference to alerting once it ships); HMAC signing semantics for consumers.

- [ ] **Step 2: Commit**

```bash
git add docs/outbound-connections.md
git commit -m "docs: admin guide for outbound connections"
```

### Task 18: Full-suite verify + manual sign-off

- [ ] **Step 1: Full build**

```bash
mvn clean verify
```

Expected: BUILD SUCCESS.

- [ ] **Step 2: UI typecheck + tests**

```bash
cd ui && npm run typecheck && npm test -- --run
```

Expected: all green.

- [ ] **Step 3: Manual acceptance checklist**

- [ ] Admin can create an outbound connection pointing at `https://httpbin.org/post` via the UI
- [ ] Test action returns HTTP 200 + latency
- [ ] TRUST_ALL shows the amber warning banner
- [ ] Non-admin (OPERATOR) can list + view but cannot create/edit
- [ ] VIEWER cannot see the admin page
- [ ] Duplicate name returns 409 with a clear message
- [ ] Startup log shows the WARN line when `trust-all=true`
- [ ] Startup fails fast when `trusted-ca-pem-paths` contains a non-existent path

- [ ] **Step 4: Nothing to commit if all passes — plan complete**

---

## Known-incomplete items carried into Plan 02

- `OutboundConnectionServiceImpl.rulesReferencing(...)` returns `[]`. Plan 02's alerting backend will implement this by scanning `alert_rules.webhooks` JSONB for `outboundConnectionId` matches.
- The `test` endpoint currently reports minimal TLS summary (`protocol="TLS"` hardcoded). Extracting the actual protocol + cipher suite + peer cert subject from Apache HttpClient 5's connection context is non-trivial; Plan 02 (or a small follow-up) can enrich this.
- OIDC retrofit to use `OutboundHttpClientFactory` is deliberately not in this plan. Audit completed in Task 4; retrofit is a separate commit when someone has cycles.

---

## Self-review

**Spec coverage** (against §6, §10 of the design):
- §6 Outbound connections — ✓ Tasks 5–12 cover schema, domain, persistence, service, REST, UI
- §10 Cross-cutting HTTP — ✓ Tasks 2–4 cover interfaces, impl, startup validation
- §20 #1 OIDC alignment — ✓ audit in Task 4, retrofit explicitly deferred
- §20 #2 secret encryption — ✓ Task 6 resolves with bespoke AES-GCM + KDF

**Placeholders:** None. All steps have concrete code. Known-incomplete section is explicit, not a placeholder.

**Type consistency:** `OutboundConnection` record fields match across service, DTO, repository, and SQL columns. `TrustMode` enum values are identical in core and DB enum type. `OutboundMethod`, `OutboundAuthKind` ditto.

**Risks to flag to executor:**
- Task 7 repo implementation has a small quirk: `toUuidArray` opens/closes a raw JDBC connection inside the method. Under heavy concurrency this is fine because JdbcTemplate pools; confirm behavior with the repo integration test (specifically, the `allowedEnvironmentIds` round-trip test).
- The controller's `@AuthenticationPrincipal UserPrincipal` type is assumed — confirm against existing admin controllers before implementing. If wrong, adjust in all `create/update/delete` handlers.
