Files
recipe/.planning/phases/02-authentication-foundation/02-PATTERNS.md
2026-04-29 20:54:13 +02:00

31 KiB

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):

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):

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):

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):

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):

[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):

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):

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):

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):

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):

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):

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):

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        configureLogging()
        initKoin {
            androidContext(this@MainApplication)
        }
    }
}
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):

@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):

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):

<vector
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:aapt="http://schemas.android.com/aapt"
        android:width="450dp"
        android:height="450dp"
        android:viewportWidth="64"
        android:viewportHeight="64">

UI contract to copy from 02-UI-SPEC.md lines 149-161:

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:

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):

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):

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):

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):

install(ContentNegotiation) {
    json()
}

DB config/logging boundary to copy (Database.kt lines 7-9, 24-39):

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:

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:

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):

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):

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        setContent {
            App()
        }
    }
}

Android manifest application/activity pattern (AndroidManifest.xml lines 1-23):

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
            android:name=".MainApplication"
            android:label="@string/app_name"
            android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
                android:exported="true"
                android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

iOS SwiftUI bootstrap pattern (iOSApp.swift lines 1-15):

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 version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>CADisableMinimumFrameDurationOnPhone</key>
        <true/>
    </dict>
</plist>

Target-specific main patterns (jvmMain/main.kt lines 8-18, webMain/main.kt lines 8-14):

fun main() {
    configureLogging()
    initKoin()
    application {
        Window(
            onCloseRequest = ::exitApplication,
            title = "recipe",
        ) {
            App()
        }
    }
}
@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):

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):

// 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:

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:

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

# 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

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }
    Database.migrate(this)
    configureRouting()
}

Phase 2 should extend this to:

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

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

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

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

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

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