# 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