diff --git a/.planning/phases/02-authentication-foundation/02-PATTERNS.md b/.planning/phases/02-authentication-foundation/02-PATTERNS.md new file mode 100644 index 0000000..62397a2 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-PATTERNS.md @@ -0,0 +1,815 @@ +# Phase 2: Authentication Foundation - Pattern Map + +**Mapped:** 2026-04-27 +**Files analyzed:** 56 new/modified files or file groups +**Analogs found:** 48 / 56 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `gradle/libs.versions.toml` | config | build-config | `gradle/libs.versions.toml` | exact | +| `shared/build.gradle.kts` | config | build-config | `server/build.gradle.kts` + `shared/build.gradle.kts` | role-match | +| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` | config | transform | `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` | role-match | +| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial | +| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial | +| `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` | test | transform | `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` | role-match | +| `server/build.gradle.kts` | config | build-config | `server/build.gradle.kts` | exact | +| `server/src/main/resources/application.conf` | config | request-response | `server/src/main/resources/application.conf` | exact | +| `server/src/main/resources/db/migration/V1__users.sql` | migration | CRUD | `server/src/main/resources/db/migration/.gitkeep` + `Database.kt` Flyway path | partial | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` | config | request-response | `server/src/main/resources/application.conf` + `Database.kt` config reads | role-match | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` | middleware | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` plugin install pattern | role-match | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` | service | CRUD | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` fail-loud DB boundary | partial | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | `V1__users.sql` planned migration + Exposed DSL decision | no existing analog | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` | route | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `configureRouting()` | exact | +| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | controller | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | exact | +| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | service | file-I/O | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | exact | +| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt` | test | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | exact | +| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` | test utility | transform | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | partial | +| `composeApp/build.gradle.kts` | config | build-config | `composeApp/build.gradle.kts` | exact | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` expect-free common seam style | partial | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` | model | event-driven | `shared` DTO style + `kotlin.test` examples | partial | +| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` | service | event-driven | `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | role-match | +| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` | service | event-driven | `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | role-match | +| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` | service | transform | `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match | +| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | service | transform | `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` | model | event-driven | `shared` model/test shape | partial | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` Koin singleton hook | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | `tools/verify-shared-pure.sh` persistence-boundary guard | partial | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | no Ktor client analog; use research Ktor bearer pattern | no existing analog | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` | service | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` client GET pattern | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact | +| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.android.kt` | service | file-I/O | `MainApplication.kt` Android context injection | role-match | +| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.ios.kt` | service | file-I/O | `KoinIos.kt` iOS actual bridge | partial | +| `composeApp/src/*Main/kotlin/dev/ulfrx/recipe/auth/HttpClientEngine.*.kt` | utility | request-response | platform `main.kt` target-specific files | role-match | +| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | controller | event-driven | `MainActivity.kt` | exact | +| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | provider | dependency-injection | `MainApplication.kt` | exact | +| `composeApp/src/androidMain/AndroidManifest.xml` | config | event-driven | `AndroidManifest.xml` | exact | +| `iosApp/iosApp/Info.plist` | config | event-driven | `Info.plist` | exact | +| `iosApp/iosApp/iOSApp.swift` | controller | event-driven | `iOSApp.swift` | exact | +| `iosApp/Podfile` | config | build-config | no existing Podfile; use `composeApp/build.gradle.kts` iOS framework block | no existing analog | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | component | transform | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` `MaterialTheme` wrapper | exact | +| `composeApp/src/commonMain/composeResources/values/strings.xml` | config | transform | `composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml` | partial | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | no ViewModel analog; use Koin/viewmodel deps and UI-SPEC method-per-action contract | no existing analog | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | no ViewModel analog; use same shape as `LoginViewModel` | no existing analog | +| `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` | test | event-driven | `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt` | role-match | +| `docs/authentik-setup.md` | docs | manual-UAT | `README.md` local development pattern if present; otherwise phase docs style | partial | + +## Pattern Assignments + +### Shared DTO + Constants Files + +Applies to: +- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` +- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` +- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` +- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` + +**Analog:** `shared/build.gradle.kts`, `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt`, `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`, `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` + +**Shared purity pattern** (`shared/build.gradle.kts` lines 16-20): +```kotlin +sourceSets { + commonMain.dependencies { + // Phase 1: intentionally empty. Domain models + DTOs land Phase 2+. + // D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here - EVER. + } +} +``` + +**Public constant pattern** (`shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` lines 1-3): +```kotlin +package dev.ulfrx.recipe + +public const val SERVER_PORT: Int = 8080 +``` + +**Serializable DTO pattern** (`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 12, 19-22): +```kotlin +import kotlinx.serialization.Serializable + +@Serializable +private data class Health( + val status: String, +) +``` + +**Test skeleton pattern** (`shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` lines 1-10): +```kotlin +package dev.ulfrx.recipe + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedCommonTest { + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} +``` + +**Planner note:** New shared symbols must be `public` because `shared` has `explicitApi()` enabled at `shared/build.gradle.kts` lines 9-10. Keep only Kotlin stdlib and `kotlinx.serialization` imports in shared DTOs. + +### Gradle Catalog + Module Build Files + +Applies to: +- `gradle/libs.versions.toml` +- `shared/build.gradle.kts` +- `server/build.gradle.kts` +- `composeApp/build.gradle.kts` + +**Analog:** existing build files + +**Version catalog organization** (`gradle/libs.versions.toml` lines 1-24, 27-43, 68-79): +```toml +[versions] +kotlin = "2.3.20" +kotlinx-serialization = "1.7.3" +ktor = "3.4.1" + +[libraries] +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } + +[plugins] +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +``` + +**Server dependency pattern** (`server/build.gradle.kts` lines 1-8, 27-39): +```kotlin +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ktor) + alias(libs.plugins.flywayPlugin) + application + id("recipe.quality") +} + +dependencies { + implementation(libs.ktor.serverCore) + implementation(libs.ktor.serverNetty) + implementation(libs.ktor.serverContentNegotiation) + implementation(libs.ktor.serializationKotlinxJson) + implementation(projects.shared) + testImplementation(libs.ktor.serverTestHost) + testImplementation(libs.kotlin.testJunit) +} +``` + +**Compose dependency pattern** (`composeApp/build.gradle.kts` lines 48-69): +```kotlin +sourceSets { + commonMain.dependencies { + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.composeViewmodel) + implementation(libs.kermit) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.components.resources) + implementation(projects.shared) + } + androidMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + } +} +``` + +**No version literal guard** (`tools/verify-no-version-literals.sh` lines 1-20): +```bash +VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null \ + | grep -v 'build-logic/build.gradle.kts' \ + | grep -vE ':[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]' \ + || true) +``` + +**Planner note:** Put new dependency versions in `gradle/libs.versions.toml`; do not add library versions directly in `*.gradle.kts` except for explicitly justified test-only literals already called out by the plan. + +### Client Koin + Logging + Auth Module + +Applies to: +- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` +- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` +- Auth singleton wiring in `AuthSession.kt` + +**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`, `Koin.kt`, `Logging.kt`, platform bootstraps + +**Koin module placeholder pattern** (`AppModule.kt` lines 1-9): +```kotlin +package dev.ulfrx.recipe.di + +import org.koin.dsl.module + +// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc. +val appModule = + module { + // intentionally empty in Phase 1 + } +``` + +**Koin startup pattern** (`Koin.kt` lines 1-10): +```kotlin +package dev.ulfrx.recipe.di + +import org.koin.core.KoinApplication +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration + +fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = + startKoin { + config?.invoke(this) + modules(appModule) + } +``` + +**Client logging bootstrap** (`Logging.kt` lines 1-8): +```kotlin +package dev.ulfrx.recipe.logging + +import co.touchlab.kermit.Logger + +fun configureLogging() { + Logger.setTag("recipe") + // Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default. +} +``` + +**Platform init order** (`MainApplication.kt` lines 8-15, `KoinIos.kt` lines 5-8): +```kotlin +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + configureLogging() + initKoin { + androidContext(this@MainApplication) + } + } +} +``` + +```kotlin +fun doInitKoin() { + configureLogging() + initKoin() +} +``` + +**Planner note:** Add `authModule` without starting Koin from composables. Wire modules from the existing `initKoin` path, preserving the one-start-per-platform rule. + +### Compose App Shell + Auth Screens + +Applies to: +- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` +- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` +- `SplashScreen.kt` +- `LoginScreen.kt` +- `LoginViewModel.kt` +- `PostLoginPlaceholderScreen.kt` +- `PostLoginViewModel.kt` +- `composeApp/src/commonMain/composeResources/values/strings.xml` + +**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`, `composeResources/drawable/compose-multiplatform.xml` + +**Current app wrapper to preserve/replace** (`App.kt` lines 25-52): +```kotlin +@Composable +@Preview +fun App() { + MaterialTheme { + var showContent by remember { mutableStateOf(false) } + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.primaryContainer) + .safeContentPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button(onClick = { showContent = !showContent }) { + Text("Click me!") + } + } + } +} +``` + +**Resource import pattern** (`App.kt` lines 21-23): +```kotlin +import org.jetbrains.compose.resources.painterResource +import recipe.composeapp.generated.resources.Res +import recipe.composeapp.generated.resources.compose_multiplatform +``` + +**Compose resource file placement analog** (`composeResources/drawable/compose-multiplatform.xml` lines 1-7): +```xml + +``` + +**UI contract to copy from `02-UI-SPEC.md` lines 149-161:** +```text +AuthState.Loading -> SplashScreen() +AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel()) +AuthState.Authenticated(user, householdId) -> PostLoginPlaceholderScreen(user, viewModel = koinViewModel()) +``` + +**Layout contract to copy from `02-UI-SPEC.md` lines 185-197:** +```kotlin +Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .safeContentPadding() + .padding(horizontal = 16.dp) +Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize(), +) +``` + +**Planner note:** Existing `App.kt` is template code. Keep the `@Composable`, `@Preview`, `MaterialTheme` shape, but replace the button/greeting body with the auth gate. All strings come from Compose Resources, not raw Polish literals. + +### Server Application, Config, Flyway, and Routes + +Applies to: +- `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/V1__users.sql` +- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` +- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` + +**Analog:** existing server files + +**Application install and routing pattern** (`Application.kt` lines 24-38): +```kotlin +fun Application.module() { + install(ContentNegotiation) { + json() + } + Database.migrate(this) + configureRouting() +} + +fun Application.configureRouting() { + routing { + get("/health") { + call.respond(Health(status = "ok")) + } + } +} +``` + +**HOCON env override pattern** (`application.conf` lines 1-18): +```hocon +ktor { + deployment { + port = 8080 + port = ${?PORT} + } +} + +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} +``` + +**Flyway runtime pattern** (`Database.kt` lines 10-39): +```kotlin +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", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) + } +} +``` + +**Planner note:** Insert auth install in `Application.module()` after `ContentNegotiation`/`CallLogging` and before protected routing. Keep `configureRouting()` testable without invoking real Postgres where possible. + +### Server JWT Auth + Principal Resolver + +Applies to: +- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` +- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` +- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` + +**Analog:** `Application.kt` plugin install, `Database.kt` DB boundary. No existing JWT or Exposed analog exists yet. + +**Plugin install style to copy** (`Application.kt` lines 24-27): +```kotlin +install(ContentNegotiation) { + json() +} +``` + +**DB config/logging boundary to copy** (`Database.kt` lines 7-9, 24-39): +```kotlin +object Database { + private val log = LoggerFactory.getLogger(Database::class.java) + + fun migrate(app: Application) { + log.info("Connecting to {} as {} and running Flyway migrations", url, user) + runCatching { + Flyway.configure().dataSource(url, user, password).load().migrate() + }.onFailure { ex -> + log.error("Flyway migration failed", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) + } + } +} +``` + +**Required auth shape from `02-CONTEXT.md` lines 62-64:** +```kotlin +install(Authentication) { + jwt("authentik") { + // verifier(jwkProvider, issuer), withIssuer, withAudience, acceptLeeway(30) + // validate block must reject null or blank sub + } +} +``` + +**Required JIT upsert from `02-CONTEXT.md` lines 81-91:** +```sql +INSERT INTO users (sub, email, display_name) +VALUES (:sub, :email, :name) +ON CONFLICT (sub) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name, + updated_at = now() +RETURNING *; +``` + +**Planner note:** Use Exposed DSL only and suspend transaction APIs for request-handling DB work. There is no local Exposed analog yet; the plan must treat `PrincipalResolver` as the first canonical server CRUD service. + +### Server Tests + +Applies to: +- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt` +- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` +- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` if added + +**Analog:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` + +**Ktor test pattern** (`ApplicationTest.kt` lines 14-29): +```kotlin +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") + } +} +``` + +**Planner note:** Compose test modules directly with `testApplication { application { ... } }`. Avoid calling `Application.module()` in tests that should not require a real Postgres unless the test sets up an in-memory DB first. + +### OIDC Platform Bootstrap + +Applies to: +- `OidcClient.kt` +- `OidcResult.kt` +- platform `OidcClient.*.kt` +- `composeApp/src/androidMain/AndroidManifest.xml` +- `iosApp/iosApp/Info.plist` +- `iosApp/iosApp/iOSApp.swift` +- `iosApp/Podfile` + +**Analog:** platform entry points and manifests + +**Android activity pattern** (`MainActivity.kt` lines 10-19): +```kotlin +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + App() + } + } +} +``` + +**Android manifest application/activity pattern** (`AndroidManifest.xml` lines 1-23): +```xml + + + + + + + + + + +``` + +**iOS SwiftUI bootstrap pattern** (`iOSApp.swift` lines 1-15): +```swift +import SwiftUI +import ComposeApp + +@main +struct iOSApp: App { + init() { + KoinIosKt.doInitKoin() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +**iOS plist pattern** (`Info.plist` lines 1-8): +```xml + + + + + CADisableMinimumFrameDurationOnPhone + + + +``` + +**Target-specific main patterns** (`jvmMain/main.kt` lines 8-18, `webMain/main.kt` lines 8-14): +```kotlin +fun main() { + configureLogging() + initKoin() + application { + Window( + onCloseRequest = ::exitApplication, + title = "recipe", + ) { + App() + } + } +} +``` + +```kotlin +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + configureLogging() + initKoin() + ComposeViewport { + App() + } +} +``` + +**Planner note:** Preserve existing Koin bootstrap while adding AppAuth callback wiring. For `recipe://callback`, register Android AppAuth receiver and iOS `CFBundleURLTypes`; do not replace unrelated app metadata. + +### Client AuthSession, Token Store, HTTP Client, and Common Tests + +Applies to: +- `AuthState.kt` +- `AuthSession.kt` +- `TokenStore.kt` +- `AuthHttpClient.kt` +- `MeClient.kt` +- `SettingsFactory.*.kt` +- `HttpClientEngine.*.kt` +- `AuthSessionTest.kt` + +**Analog:** Koin module pattern, platform main files, `ComposeAppCommonTest.kt`. There is no existing Ktor client or settings storage analog. + +**Common test skeleton** (`ComposeAppCommonTest.kt` lines 1-10): +```kotlin +package dev.ulfrx.recipe + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComposeAppCommonTest { + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} +``` + +**Koin module registration analog** (`AppModule.kt` lines 5-9): +```kotlin +// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc. +val appModule = + module { + // intentionally empty in Phase 1 + } +``` + +**Required Ktor client bearer shape from `02-RESEARCH.md` lines 313-329:** +```kotlin +install(Auth) { + bearer { + loadTokens { + authSession.currentBearerTokens() + } + refreshTokens { + authSession.refreshBearerTokens() + } + sendWithoutRequest { request -> + request.url.host == apiHost + } + } +} +``` + +**Required auth state shape from `02-CONTEXT.md` lines 97-107:** +```kotlin +sealed class AuthState { + data object Loading : AuthState() + data object Unauthenticated : AuthState() + data class Authenticated( + val user: User, + val householdId: HouseholdId? = null, + ) : AuthState() +} +``` + +**Planner note:** This group becomes the client auth canonical pattern for later phases. Keep collaborators injectable behind small interfaces so common tests can fake OIDC and `/me` without platform AppAuth. + +## Shared Patterns + +### Shared Module Purity + +**Source:** `tools/verify-shared-pure.sh` lines 1-15 +**Apply to:** all files under `shared/src/commonMain` + +```bash +# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight. +# Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime. +VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true) +``` + +### Server Startup Order + +**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 24-30 +**Apply to:** `Application.kt`, auth plugin install, route wiring + +```kotlin +fun Application.module() { + install(ContentNegotiation) { + json() + } + Database.migrate(this) + configureRouting() +} +``` + +Phase 2 should extend this to: +```text +ContentNegotiation -> CallLogging redaction -> Database.migrate -> Database.connect -> configureAuth -> configureRouting +``` + +### Server Logging + +**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` lines 7-8, 24, 36-39 +**Apply to:** server DB/auth services + +```kotlin +object Database { + private val log = LoggerFactory.getLogger(Database::class.java) + + log.info("Connecting to {} as {} and running Flyway migrations", url, user) + log.error("Flyway migration failed", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) +} +``` + +### Client Logging + +**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` lines 1-8 +**Apply to:** `AuthSession`, OIDC client wrappers + +```kotlin +import co.touchlab.kermit.Logger + +fun configureLogging() { + Logger.setTag("recipe") + // Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default. +} +``` + +Use `Logger.withTag("auth")` for auth flow diagnostics, but never log token bodies or `Authorization` headers. + +### Koin Startup + +**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` lines 7-10 and platform callers +**Apply to:** `authModule`, ViewModels, OIDC clients, settings factories + +```kotlin +fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = + startKoin { + config?.invoke(this) + modules(appModule) + } +``` + +### HOCON Env Vars + +**Source:** `server/src/main/resources/application.conf` lines 11-18 +**Apply to:** `oidc.issuer`, `oidc.audience`, `oidc.jwksUrl`, `oidc.leewaySeconds` + +```hocon +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} +``` + +### Ktor Route Tests + +**Source:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` lines 17-29 +**Apply to:** `JwtAuthTest`, `MeRouteTest` + +```kotlin +testApplication { + application { + install(ContentNegotiation) { + json() + } + configureRouting() + } + val response = client.get("/health") + assertEquals(HttpStatusCode.OK, response.status) +} +``` + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | No Exposed table exists yet. Phase 2 establishes first server table DSL pattern. | +| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` JWT details | middleware | request-response | Ktor server exists, but no Authentication/JWT plugin exists yet. Use `02-CONTEXT.md` D-21/D-22 and Ktor docs from research. | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | No Ktor client code exists yet. Use `02-RESEARCH.md` Ktor bearer shape. | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | No multiplatform-settings usage exists yet. Keep explicit platform store seam. | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | Koin ViewModel deps exist, but no ViewModel classes exist yet. Use method-per-action `StateFlow` convention from project docs/UI spec. | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | Same as LoginViewModel. | +| `iosApp/Podfile` | config | build-config | No existing Podfile. Follow Plan 03 CocoaPods DSL and iOS deployment target from `composeApp/build.gradle.kts`. | + +## Metadata + +**Analog search scope:** `composeApp/`, `server/`, `shared/`, `iosApp/`, `build-logic/`, `gradle/`, `tools/`, Phase 1 summaries and Phase 2 plan drafts. +**Files scanned:** 75+ code/config/planning files via `rg --files`, `find`, and targeted `nl -ba` reads. +**Pattern extraction date:** 2026-04-27 + diff --git a/.planning/phases/02-authentication-foundation/02-RESEARCH.md b/.planning/phases/02-authentication-foundation/02-RESEARCH.md index f5bb183..66b87e7 100644 --- a/.planning/phases/02-authentication-foundation/02-RESEARCH.md +++ b/.planning/phases/02-authentication-foundation/02-RESEARCH.md @@ -367,22 +367,22 @@ Source: Phase 2 context. [VERIFIED: `.planning/phases/02-authentication-foundati |---|-------|---------|---------------| | A1 | `com.auth0:jwks-rsa` may need an explicit alias if Ktor's auth JWT dependency does not expose the builder cleanly. | Standard Stack | Minor Gradle dependency task may be missing. | -## Open Questions +## Open Questions (RESOLVED) -1. **Android secure token storage final choice** +1. **RESOLVED — Android secure token storage final choice** - What we know: no-arg/default multiplatform-settings is not encrypted on Android; AndroidX Security Crypto encrypts preferences but is deprecated. [CITED: https://github.com/russhwolf/multiplatform-settings] [CITED: https://developer.android.com/jetpack/androidx/releases/security] - - What's unclear: whether the user accepts deprecated `EncryptedSharedPreferences` for v1 or wants direct Android Keystore-backed storage. [VERIFIED: docs comparison] - - Recommendation: planner should add Wave 0 decision task; default to direct platform-specific `SecureAuthStateStore` with an implementation that can be swapped without touching `AuthSession`. [VERIFIED: docs comparison] + - Decision from PLAN.md: Phase 2 uses AndroidX Security Crypto `EncryptedSharedPreferences` behind an explicit `SecureAuthStateStore.android.kt` implementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind the `SecureAuthStateStore` seam so a future Android Keystore-backed implementation can replace it without touching `AuthSession`. + - Guardrail: auth code must not use no-arg `Settings()` or ordinary `SharedPreferences` for tokens; Plan 03 includes grep-verifiable acceptance criteria for this. -2. **Exposed version and suspend transaction import** +2. **RESOLVED — Exposed version and suspend transaction import** - What we know: current Exposed docs use `suspendTransaction`; project context says `newSuspendedTransaction`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] - - What's unclear: which Exposed version will be pinned in `libs.versions.toml`. - - Recommendation: pin Exposed first, then write imports from that version's docs/source. [VERIFIED: docs comparison] + - Decision from PLAN.md: Plan 02 verifies the exact suspend transaction API/import against the pinned Exposed dependency before implementing DB code. Expected for the current catalog path is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, execution must use that exact import and record the choice in `02-02-SUMMARY.md`. + - Guardrail: no blocking `transaction {}` inside suspend route code. -3. **Ktor patch bump** +3. **RESOLVED — Ktor patch bump** - What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] - - What's unclear: whether Phase 2 should include a dependency patch bump. - - Recommendation: if bumping, bump the single `ktor` version ref once and run full `./gradlew build`; otherwise add auth artifacts at 3.4.1 for catalog consistency. [VERIFIED: project version catalog] + - Decision from PLAN.md: Phase 2 keeps Ktor pinned to the existing catalog version (`3.4.1`) and adds auth artifacts against that same version ref. A patch bump is deferred unless implementation reveals a concrete incompatibility. + - Guardrail: do not opportunistically bump Ktor during auth implementation without an explicit failing command or compatibility reason. ## Environment Availability diff --git a/.planning/phases/02-authentication-foundation/02-VALIDATION.md b/.planning/phases/02-authentication-foundation/02-VALIDATION.md index f0b89b6..832c97a 100644 --- a/.planning/phases/02-authentication-foundation/02-VALIDATION.md +++ b/.planning/phases/02-authentication-foundation/02-VALIDATION.md @@ -84,11 +84,11 @@ created: 2026-04-27 ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 120s for quick checks +- [x] All tasks have `` verify or Wave 0 dependencies represented in Phase 2 plans +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 missing references are mapped into Phase 2 plan tasks +- [x] No watch-mode flags +- [x] Feedback latency target < 120s for quick checks is documented - [ ] `nyquist_compliant: true` set in frontmatter -**Approval:** pending +**Approval:** plan-ready 2026-04-27; execution must keep `nyquist_compliant: false` and `wave_0_complete: false` until Wave 0 tests/manual-UAT artifacts actually exist.