--- phase: 01-project-infrastructure-module-wiring plan: 05 type: execute wave: 2 depends_on: [01, 02] files_modified: - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt - server/src/main/resources/application.conf - server/src/main/resources/db/migration/.gitkeep - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt autonomous: true requirements: [INFRA-02] requirements_addressed: [INFRA-02] must_haves: truths: - "GET /health returns 200 with Content-Type: application/json and body {\"status\":\"ok\"} (D-16)" - "Server reads database.url / database.user / database.password from application.conf, with localhost defaults and env overrides via HOCON ${?X} syntax (PITFALL #5)" - "Flyway runs Flyway.configure().dataSource(url, user, password).locations(\"classpath:db/migration\").load().migrate() during Application.module() startup" - "Server fails loudly with IllegalStateException if Postgres is unreachable — the exception is thrown from Database.migrate() and NOT swallowed" - "server/src/main/resources/db/migration/ directory exists (with .gitkeep) so Flyway.locations classpath resolution finds it even when empty" - "ApplicationTest.kt has a test named 'health endpoint returns 200 with status ok' (or similar) that does NOT require a running Postgres — it composes routing in isolation" - "Application.kt uses explicit Ktor imports (no wildcard imports) so D-11 allWarningsAsErrors is satisfied" artifacts: - path: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" provides: "main() → embeddedServer(Netty, SERVER_PORT, ::module).start(); Application.module() installs ContentNegotiation(json), invokes Database.migrate(this), and registers GET /health" exports: ["main", "Application.module"] - path: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" provides: "object Database { fun migrate(app: Application) } — reads HOCON config, runs Flyway, throws IllegalStateException on failure" exports: ["Database"] - path: "server/src/main/resources/application.conf" provides: "HOCON config with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})" - path: "server/src/main/resources/db/migration/.gitkeep" provides: "Empty directory placeholder ensuring classpath:db/migration resolves for Flyway even when no SQL files exist yet" - path: "server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt" provides: "ApplicationTest with /health route assertion — composes routing without calling Database.migrate (no Postgres required)" key_links: - from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" to: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" via: "Database.migrate(this) inside Application.module()" pattern: "Database\\.migrate\\(this\\)" - from: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" to: "server/src/main/resources/application.conf" via: "app.environment.config.property(\"database.url\").getString() etc." pattern: "config\\.property\\(\"database\\." - from: "Flyway.configure().locations(...)" to: "server/src/main/resources/db/migration/" via: "classpath:db/migration" pattern: "classpath:db/migration" --- Deliver the server's running-but-empty state: a `GET /health` route returning `{"status":"ok"}`, HOCON-based config (`application.conf`) with env-var overrides, a `Database` object that runs Flyway against Postgres at boot time (failing loudly if Postgres is unreachable), and an updated `ApplicationTest.kt` that asserts the route in isolation without requiring a running database. Also scaffold `server/src/main/resources/db/migration/` as an empty directory so Flyway's classpath resolution succeeds before Phase 3 adds `V1__init.sql`. Purpose: This plan closes D-16 — Phase 3 drops its first migration into an already-working migrator; Phase 11 deploys to the homelab with the same Ktor HOCON config reading real env vars. The fail-loud contract for unreachable Postgres is load-bearing: it surfaces config errors at boot, not at first 5xx. Output: 2 Kotlin source files (Application.kt rewrite + Database.kt new), 1 HOCON config, 1 directory placeholder, 1 test rewrite. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md @.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md @.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md @.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md @server/src/main/kotlin/dev/ulfrx/recipe/Application.kt @server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt @shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt @CLAUDE.md From io.ktor.server.application: ```kotlin interface Application interface ApplicationEnvironment { val config: ApplicationConfig } interface ApplicationConfig { fun property(path: String): ApplicationConfigValue fun propertyOrNull(path: String): ApplicationConfigValue? } interface ApplicationConfigValue { fun getString(): String } ``` From io.ktor.server.engine + io.ktor.server.netty: ```kotlin fun embeddedServer(factory: ApplicationEngineFactory<...>, port: Int, host: String, module: Application.() -> Unit): EmbeddedServer object Netty : ApplicationEngineFactory<...> ``` From io.ktor.server.plugins.contentnegotiation + io.ktor.serialization.kotlinx.json: ```kotlin object ContentNegotiation : BaseApplicationPlugin<...> fun ContentNegotiationConfig.json() // installs kotlinx.serialization JSON converter ``` From io.ktor.server.routing + io.ktor.server.response: ```kotlin fun Application.routing(block: Route.() -> Unit) fun Route.get(path: String, handler: suspend RoutingContext.() -> Unit) suspend fun ApplicationCall.respond(message: Any) ``` From io.ktor.server.testing (in testImplementation via recipe.jvm.server): ```kotlin fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit) // ApplicationTestBuilder provides: fun application(block: Application.() -> Unit) val client: HttpClient ``` From org.flywaydb.core: ```kotlin object Flyway { fun configure(): FluentConfiguration } // FluentConfiguration: fun dataSource(url: String, user: String, password: String): FluentConfiguration fun locations(vararg locations: String): FluentConfiguration fun baselineOnMigrate(b: Boolean): FluentConfiguration fun validateOnMigrate(b: Boolean): FluentConfiguration fun cleanDisabled(b: Boolean): FluentConfiguration fun load(): Flyway // Flyway instance: fun migrate(): MigrateResult ``` From kotlinx.serialization: ```kotlin @Serializable ``` From org.slf4j: ```kotlin object LoggerFactory { fun getLogger(clazz: Class<*>): Logger } // org.slf4j.Logger: .info(msg: String, vararg args: Any), .error(msg: String, t: Throwable) ``` From shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (DO NOT modify): ```kotlin package dev.ulfrx.recipe const val SERVER_PORT: Int = 8080 // or whatever current value is ``` Current server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (to replace): ```kotlin package dev.ulfrx.recipe import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.response.* import io.ktor.server.routing.* fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } } ``` Current server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (to replace): ```kotlin // testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion ``` Task 1: Create application.conf + db/migration/.gitkeep + Database.kt server/src/main/resources/application.conf, server/src/main/resources/db/migration/.gitkeep, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 988-1023 (canonical Database.kt — SLF4J variant since server uses Logback not Kermit) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1029-1051 (canonical application.conf HOCON) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 692-717 (PITFALL #5 — `${?X}` env-var HOCON syntax) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 719-724 (PITFALL #6 — Flyway runtime API, not plugin at build time) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 990-1076 (Database.kt + application.conf + .gitkeep deltas) - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server /health + Flyway + Postgres env overrides) - server/build.gradle.kts (verify Plan 03 made `implementation(projects.shared)` present so `SERVER_PORT` is still reachable) Create three files. **File 1: `server/src/main/resources/application.conf`** (HOCON, 01-RESEARCH.md lines 1031-1051): ```hocon ktor { deployment { port = 8080 port = ${?PORT} } application { modules = [ dev.ulfrx.recipe.ApplicationKt.module ] } } database { url = "jdbc:postgresql://localhost:5432/recipe" url = ${?DATABASE_URL} user = "recipe" user = ${?DATABASE_USER} password = "recipe" password = ${?DATABASE_PASSWORD} } ``` CRITICAL (PITFALL #5): - The two-line `url = "default"; url = ${?DATABASE_URL}` pattern is MANDATORY. `${?X}` is optional substitution — the second line is a no-op when `DATABASE_URL` is unset, and an override when it is set. Do NOT use `${X}` (required — crashes if unset) or `${X:default}` (wrong HOCON syntax). - `"jdbc:postgresql://localhost:5432/recipe"`, `"recipe"`, `"recipe"` MATCH the docker-compose defaults in Plan 06 exactly — allows `docker compose up -d postgres && ./gradlew :server:run` with zero extra env config. - `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` — even though `main()` uses programmatic `embeddedServer(...)` in Application.kt, this key is informational for Ktor's HOCON config loader and future EngineMain switching. **File 2: `server/src/main/resources/db/migration/.gitkeep`** — empty zero-byte file. Git does not track empty directories; this marker ensures `server/src/main/resources/db/migration/` ships in the repo so `classpath:db/migration` resolves for Flyway. Phase 3 drops `V1__init.sql` here. **File 3: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** (SLF4J variant — the server uses Logback already, NOT Kermit; RESEARCH.md lines 996-1023 + lines 1025-1027 explain the logger choice): ```kotlin package dev.ulfrx.recipe import io.ktor.server.application.Application import org.flywaydb.core.Flyway import org.slf4j.LoggerFactory object Database { private val log = LoggerFactory.getLogger(Database::class.java) fun migrate(app: Application) { val url = app.environment.config.property("database.url").getString() val user = app.environment.config.property("database.user").getString() val password = app.environment.config.property("database.password").getString() log.info("Connecting to {} as {} and running Flyway migrations", url, user) runCatching { Flyway.configure() .dataSource(url, user, password) .locations("classpath:db/migration") .baselineOnMigrate(true) .validateOnMigrate(true) .cleanDisabled(true) .load() .migrate() }.onFailure { ex -> log.error("Flyway migration failed — cannot start server", ex) throw IllegalStateException("Database unreachable or migration failed", ex) } } } ``` CRITICAL: - `throw IllegalStateException(...)` is the fail-loud contract (D-16). Do NOT wrap it in a generic `try { } catch { return false }` — the server MUST refuse to start if the DB is unreachable. - Use SLF4J (`LoggerFactory.getLogger(...)`), NOT Kermit. The server has Logback wired via `logback.xml`; Kermit is the CLIENT logger (composeApp only). - Log credentials are NOT logged — only `url` and `user` appear in the info line. `password` is used for `dataSource(...)` only. - `cleanDisabled = true` prevents accidental `flywayClean` wiping tables in dev/prod (matches `recipe.jvm.server.gradle.kts` plugin config — double-enforcement). - `baselineOnMigrate = true` tolerates an existing DB with no Flyway history (defensive — Phase 1's DB is empty, Phase 11's homelab DB may pre-exist). - `locations("classpath:db/migration")` points to the resource directory the `.gitkeep` keeps alive. test -f server/src/main/resources/application.conf && test -f server/src/main/resources/db/migration/.gitkeep && test -f server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'port = 8080' server/src/main/resources/application.conf && grep -q 'port = \${?PORT}' server/src/main/resources/application.conf && grep -q 'url = "jdbc:postgresql://localhost:5432/recipe"' server/src/main/resources/application.conf && grep -q 'url = \${?DATABASE_URL}' server/src/main/resources/application.conf && grep -q 'user = "recipe"' server/src/main/resources/application.conf && grep -q 'user = \${?DATABASE_USER}' server/src/main/resources/application.conf && grep -q 'password = "recipe"' server/src/main/resources/application.conf && grep -q 'password = \${?DATABASE_PASSWORD}' server/src/main/resources/application.conf && grep -q 'object Database' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.flywaydb.core.Flyway' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.slf4j.LoggerFactory' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'cleanDisabled(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'baselineOnMigrate(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'throw IllegalStateException' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'classpath:db/migration' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt - `server/src/main/resources/application.conf` exists and contains exactly 6 env-var override lines (`port = ${?PORT}`, `url = ${?DATABASE_URL}`, `user = ${?DATABASE_USER}`, `password = ${?DATABASE_PASSWORD}` plus the two defaults for `port = 8080` and the DB trio) - `application.conf` default values match docker-compose defaults: URL `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe` - `application.conf` contains `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` - `server/src/main/resources/db/migration/.gitkeep` exists (zero-byte file acceptable) - `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` exists and declares `object Database` - `Database.kt` imports `io.ktor.server.application.Application`, `org.flywaydb.core.Flyway`, `org.slf4j.LoggerFactory` - `Database.kt` defines `fun migrate(app: Application)` that reads `app.environment.config.property("database.url|user|password").getString()` - `Database.kt` body contains `Flyway.configure().dataSource(url, user, password).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()` (all chained) - `Database.kt` wraps the migration in `runCatching { ... }.onFailure { ... throw IllegalStateException(...) }` (fail-loud contract) - `Database.kt` does NOT import `co.touchlab.kermit.Logger` (server uses SLF4J) - `Database.kt` log.info line does NOT format the password value (only url + user in the format string) HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist. Task 2: Rewrite Application.kt to install ContentNegotiation, call Database.migrate, expose /health server/src/main/kotlin/dev/ulfrx/recipe/Application.kt - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (current 20 lines — target of rewrite) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 952-985 (canonical Application.kt with ContentNegotiation + /health) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 940-986 (Application.kt deltas) - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (sentinel JSON body for /health — Claude's discretion; use trivial `{"status":"ok"}`) - shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (verify `SERVER_PORT` constant is defined) Replace the entire content of `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` with: ```kotlin package dev.ulfrx.recipe import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.response.respond import io.ktor.server.routing.get import io.ktor.server.routing.routing import kotlinx.serialization.Serializable fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) .start(wait = true) } @Serializable private data class Health(val status: String) fun Application.module() { install(ContentNegotiation) { json() } Database.migrate(this) configureRouting() } fun Application.configureRouting() { routing { get("/health") { call.respond(Health(status = "ok")) } } } ``` DELETIONS: - DROP the wildcard imports (`io.ktor.server.application.*`, `io.ktor.server.engine.*`, `io.ktor.server.netty.*`, `io.ktor.server.response.*`, `io.ktor.server.routing.*`) — replaced with explicit imports to satisfy D-11 allWarningsAsErrors (wildcard-unused warnings would fail the build) - DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health` ADDITIONS: - ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response serialization - ADD `Database.migrate(this)` call inside `Application.module()` — fails loudly if Postgres unreachable - ADD `@Serializable private data class Health(val status: String)` — the /health response shape - ADD `Application.configureRouting()` extension function — extracted from `module()` so the test (Task 3) can compose routing WITHOUT invoking `Database.migrate()` KEEP: - `package dev.ulfrx.recipe` (unchanged) - `fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }` — programmatic boot, unchanged shape - `SERVER_PORT` constant is referenced from `shared/` (unchanged) CRITICAL: - The extraction of `configureRouting()` from `module()` is load-bearing for the test. Task 3 needs to test routing without calling `Database.migrate(this)` (which requires a real Postgres). - `install(ContentNegotiation) { json() }` — MUST be installed before any route returns a `@Serializable` type. Both `module()` (for production) and the test (Task 3) must install it. grep -q '^package dev.ulfrx.recipe$' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.server.plugins.contentnegotiation.ContentNegotiation' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.serialization.kotlinx.json.json' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import kotlinx.serialization.Serializable' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -qE 'import io\.ktor\.server\.application\.\*' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'install(ContentNegotiation)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'Database.migrate(this)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'get("/health")' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'data class Health(val status: String)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'fun Application.configureRouting()' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -q 'call.respondText' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'embeddedServer(Netty, port = SERVER_PORT' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt - `Application.kt` has no wildcard imports (`import X.*`) — every `io.ktor.*` import is explicit - `Application.kt` imports `io.ktor.server.plugins.contentnegotiation.ContentNegotiation`, `io.ktor.serialization.kotlinx.json.json`, `kotlinx.serialization.Serializable` - `Application.kt` defines `@Serializable private data class Health(val status: String)` - `Application.module()` body calls, in order: `install(ContentNegotiation) { json() }`, then `Database.migrate(this)`, then `configureRouting()` - `Application.configureRouting()` is a top-level extension function containing the `routing { get("/health") { call.respond(Health(status = "ok")) } }` block - `main()` is unchanged from its current shape: `embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true)` - No `get("/")` route remains (template root greeting is removed) - No `call.respondText(...)` in Application.kt (Health returned via `call.respond(Health(...))` → kotlinx-json serializer) Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability. Task 3: Rewrite ApplicationTest.kt to assert GET /health returns 200 with JSON body server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (current 20-line content — target of rewrite) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1084-1125 (canonical ApplicationTest.kt variant) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1079-1125 (test delta explaining the no-Postgres-required refactor) - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (the freshly rewritten file — the test references `configureRouting()` from this file) - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 52-53 (automated command this test must satisfy: `./gradlew :server:test --tests "*Health*"`) Replace the entire content of `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` with: ```kotlin package dev.ulfrx.recipe import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.install import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.testing.testApplication import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class ApplicationTest { @Test fun `health endpoint returns 200 with status ok`() = testApplication { application { install(ContentNegotiation) { json() } configureRouting() } val response = client.get("/health") assertEquals(HttpStatusCode.OK, response.status) val body = response.bodyAsText() assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body") assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body") } } ``` CRITICAL: - The test invokes `configureRouting()` directly (extracted in Task 2) and does NOT call `Database.migrate(...)`. This is the KEY refactor: the test runs without a running Postgres, so `./gradlew :server:test` can succeed in CI / fresh clones. - `install(ContentNegotiation) { json() }` is explicitly installed inside `application { }` — because the production `Application.module()` installs it, but the test composes only `configureRouting()` and must install the plugin itself. - Imports are explicit (no wildcards) to satisfy D-11 allWarningsAsErrors. - Assertions check for `"status"` and `"ok"` substrings in the JSON body — this is a structural check that works regardless of JSON field ordering. - The test function name uses backtick-quoted natural-language identifier (`` `health endpoint returns 200 with status ok` ``) — standard Kotlin test-naming convention; the test will run via `./gradlew :server:test --tests "*health*"` or similar wildcards. DELETIONS: - DROP the existing `testRoot()` test — it asserted the template's `/` route response with `"Ktor: ${Greeting().greet()}"`, which no longer exists. - DROP wildcard imports `io.ktor.client.request.*`, `io.ktor.client.statement.*`, `io.ktor.http.*`, `io.ktor.server.testing.*`, `kotlin.test.*`. test -f server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'health endpoint returns 200' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'configureRouting()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Database.migrate' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'install(ContentNegotiation)' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'client.get("/health")' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'HttpStatusCode.OK' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'testRoot' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Greeting().greet()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -qE 'import kotlin\.test\.\*' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && cd /Users/rwilk/dev/repo/recipe && ./gradlew :server:test --tests "*health*" -q 2>&1 | tail -5 && echo "gradle exit: $?" - `ApplicationTest.kt` defines exactly one `@Test` method whose name contains `health` (case-insensitive) - Test body invokes `configureRouting()` and does NOT invoke `Database.migrate(...)` (no-Postgres invariant) - Test installs `ContentNegotiation { json() }` inside `application { ... }` - Test asserts `response.status == HttpStatusCode.OK` - Test asserts response body contains substring `"status"` AND `"ok"` - No wildcard imports - No reference to the removed `testRoot`, `Greeting`, or `respondText` — the old template test is fully replaced - `./gradlew :server:test --tests "*health*"` runs and exits 0 (proves the test compiles AND passes; no Postgres needed because `configureRouting()` is composed directly) /health test passes without requiring Postgres; old template test removed. ## Trust Boundaries | Boundary | Description | |----------|-------------| | HTTP client (unauthenticated) → GET /health | `/health` is intentionally unauthenticated (observability); reveals only `{"status":"ok"}` — no implementation detail, no version, no uptime. | | Ktor process → Postgres (JDBC) | HOCON defaults connect to `localhost:5432` with dev credentials. Real credentials arrive via `DATABASE_URL`/`DATABASE_USER`/`DATABASE_PASSWORD` env vars in Phase 11 homelab deploy. | | Developer → server/src/main/resources/application.conf | Committed to git; MUST contain only non-secret dev defaults. Real secrets never land in `application.conf`. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-01-05-01 | Information Disclosure | `/health` endpoint leaking implementation details | mitigate | Body is `{"status":"ok"}` only — no version, no commit hash, no uptime, no DB state. Per ASVS V14 guidance + 01-RESEARCH.md § Security Domain. | | T-01-05-02 | Information Disclosure | `application.conf` committed with real secrets | mitigate | Defaults are non-secret localhost creds (`recipe/recipe/recipe`). Real secrets MUST arrive via `${?DATABASE_URL}` env override — never committed. Task 1 acceptance criteria enforces the six `${?X}` lines. | | T-01-05-03 | Tampering / Destruction | `flywayClean` wiping DB | mitigate | `cleanDisabled(true)` is set in BOTH `recipe.jvm.server.gradle.kts` (plugin CLI guard) AND in `Database.kt` runtime call (programmatic guard). Double-enforced per RESEARCH.md § Security Domain. | | T-01-05-04 | Denial of Service | Server silently runs with broken DB | mitigate | `Database.migrate()` throws `IllegalStateException` on any Flyway/JDBC error — server cannot start. Fail-loud contract (D-16) prevents stealth failure modes. | | T-01-05-05 | Error Handling (leaky stack traces) | 500 responses exposing internal stack | accept | No 500 handler registered yet; Ktor's default returns generic 500. Phase 2+ may introduce a StatusPages handler; Phase 1 scope is /health only which cannot 500 under normal operation. | | T-01-05-06 | Supply Chain | Flyway 12.4.0 + flyway-database-postgresql transitive deps | mitigate | Pinned versions via catalog (Plan 01). Postgres JDBC 42.7.10 pinned. No `latest.release` ranges. | Phase-level verification for this plan: - Task 3 `` runs `./gradlew :server:test --tests "*health*"` which proves: - Application.kt compiles (confirms Task 2's explicit imports are correct) - ApplicationTest.kt compiles (confirms Task 3's imports are correct) - The /health route returns 200 with JSON containing `"status"` and `"ok"` - Database.migrate is NOT required for the test (no Postgres needed in CI — D-11 test-runtime invariant) - `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified; server/build.gradle.kts was rewritten in Plan 03, untouched here). - Manual verification (deferred to Plan 07 or manual step): - `docker compose up -d postgres && ./gradlew :server:run & sleep 5 && curl -sf http://localhost:8080/health | grep '"ok"'` — proves end-to-end boot + route + DB migration path. - `server/src/main/resources/application.conf` exists with HOCON + 6 env overrides - `server/src/main/resources/db/migration/.gitkeep` exists - `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` runs Flyway with fail-loud contract - `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` installs ContentNegotiation, calls Database.migrate, exposes GET /health returning `{"status":"ok"}` - `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` passes via `./gradlew :server:test --tests "*health*"` WITHOUT a running Postgres After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-05-SUMMARY.md` recording: files created/modified, HOCON env-var pattern used (the `${?X}` two-line form), the fail-loud Database.migrate contract, and the `./gradlew :server:test` result.