32 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, requirements_addressed, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | requirements_addressed | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-project-infrastructure-module-wiring | 05 | execute | 2 |
|
|
true |
|
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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.mdFrom io.ktor.server.application:
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:
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:
object ContentNegotiation : BaseApplicationPlugin<...>
fun ContentNegotiationConfig.json() // installs kotlinx.serialization JSON converter
From io.ktor.server.routing + io.ktor.server.response:
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):
fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit)
// ApplicationTestBuilder provides:
fun application(block: Application.() -> Unit)
val client: HttpClient
From org.flywaydb.core:
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:
@Serializable
From org.slf4j:
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):
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):
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):
// testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion
File 1: server/src/main/resources/application.conf (HOCON, 01-RESEARCH.md lines 1031-1051):
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 whenDATABASE_URLis 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 — allowsdocker compose up -d postgres && ./gradlew :server:runwith zero extra env config.modules = [ dev.ulfrx.recipe.ApplicationKt.module ]— even thoughmain()uses programmaticembeddedServer(...)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):
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 generictry { } 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 vialogback.xml; Kermit is the CLIENT logger (composeApp only). - Log credentials are NOT logged — only
urlanduserappear in the info line.passwordis used fordataSource(...)only. cleanDisabled = trueprevents accidentalflywayCleanwiping tables in dev/prod (matchesrecipe.jvm.server.gradle.ktsplugin config — double-enforcement).baselineOnMigrate = truetolerates 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.gitkeepkeeps 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 <acceptance_criteria>server/src/main/resources/application.confexists and contains exactly 6 env-var override lines (port = ${?PORT},url = ${?DATABASE_URL},user = ${?DATABASE_USER},password = ${?DATABASE_PASSWORD}plus the two defaults forport = 8080and the DB trio)application.confdefault values match docker-compose defaults: URLjdbc:postgresql://localhost:5432/recipe, userrecipe, passwordrecipeapplication.confcontainsmodules = [ dev.ulfrx.recipe.ApplicationKt.module ]server/src/main/resources/db/migration/.gitkeepexists (zero-byte file acceptable)server/src/main/kotlin/dev/ulfrx/recipe/Database.ktexists and declaresobject DatabaseDatabase.ktimportsio.ktor.server.application.Application,org.flywaydb.core.Flyway,org.slf4j.LoggerFactoryDatabase.ktdefinesfun migrate(app: Application)that readsapp.environment.config.property("database.url|user|password").getString()Database.ktbody containsFlyway.configure().dataSource(url, user, password).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()(all chained)Database.ktwraps the migration inrunCatching { ... }.onFailure { ... throw IllegalStateException(...) }(fail-loud contract)Database.ktdoes NOT importco.touchlab.kermit.Logger(server uses SLF4J)Database.ktlog.info line does NOT format the password value (only url + user in the format string) </acceptance_criteria> HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist.
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@Serializableresponse serialization - ADD
Database.migrate(this)call insideApplication.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 frommodule()so the test (Task 3) can compose routing WITHOUT invokingDatabase.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 shapeSERVER_PORTconstant is referenced fromshared/(unchanged)
CRITICAL:
- The extraction of
configureRouting()frommodule()is load-bearing for the test. Task 3 needs to test routing without callingDatabase.migrate(this)(which requires a real Postgres). install(ContentNegotiation) { json() }— MUST be installed before any route returns a@Serializabletype. Bothmodule()(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 <acceptance_criteria>Application.kthas no wildcard imports (import X.*) — everyio.ktor.*import is explicitApplication.ktimportsio.ktor.server.plugins.contentnegotiation.ContentNegotiation,io.ktor.serialization.kotlinx.json.json,kotlinx.serialization.SerializableApplication.ktdefines@Serializable private data class Health(val status: String)Application.module()body calls, in order:install(ContentNegotiation) { json() }, thenDatabase.migrate(this), thenconfigureRouting()Application.configureRouting()is a top-level extension function containing therouting { get("/health") { call.respond(Health(status = "ok")) } }blockmain()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 viacall.respond(Health(...))→ kotlinx-json serializer) </acceptance_criteria> Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability.
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 callDatabase.migrate(...). This is the KEY refactor: the test runs without a running Postgres, so./gradlew :server:testcan succeed in CI / fresh clones. install(ContentNegotiation) { json() }is explicitly installed insideapplication { }— because the productionApplication.module()installs it, but the test composes onlyconfigureRouting()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: $?" <acceptance_criteria>ApplicationTest.ktdefines exactly one@Testmethod whose name containshealth(case-insensitive)- Test body invokes
configureRouting()and does NOT invokeDatabase.migrate(...)(no-Postgres invariant) - Test installs
ContentNegotiation { json() }insideapplication { ... } - 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, orrespondText— 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 becauseconfigureRouting()is composed directly) </acceptance_criteria> /health test passes without requiring Postgres; old template test removed.
<threat_model>
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. |
| </threat_model> |
-
Task 3
<automated>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.shcontinues 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.
<success_criteria>
server/src/main/resources/application.confexists with HOCON + 6 env overridesserver/src/main/resources/db/migration/.gitkeepexistsserver/src/main/kotlin/dev/ulfrx/recipe/Database.ktruns Flyway with fail-loud contractserver/src/main/kotlin/dev/ulfrx/recipe/Application.ktinstalls ContentNegotiation, calls Database.migrate, exposes GET /health returning{"status":"ok"}server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.ktpasses via./gradlew :server:test --tests "*health*"WITHOUT a running Postgres </success_criteria>