feat(02-01): land Constants and MeResponse/User DTOs in shared

GREEN phase of TDD task 02-01-01. Adds the load-bearing Phase 2
contract that downstream plans compile against:

- Constants.kt: OIDC_ISSUER (trailing slash, placeholder homelab host),
  OIDC_CLIENT_ID = recipe-app, OIDC_REDIRECT_URI = recipe://callback,
  API_BASE_URL, plus moved SERVER_PORT for one shared config object.
- dto/User.kt: domain identity (id/sub/email/displayName), id is String
  to keep shared free of UUID library deps (D-19 / INFRA-06).
- dto/MeResponse.kt: @Serializable wire DTO for GET /api/v1/me with a
  one-to-one toUser() mapper. Stable for Phase 3 to add householdId
  via ignoreUnknownKeys.
- Removes the now-redundant shared/.gitkeep placeholder.

Verification:
- ./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata: PASS
- ./tools/verify-shared-pure.sh: PASS
- All grep acceptance criteria for Task 1 satisfied
This commit is contained in:
2026-04-28 10:45:04 +02:00
parent 6504b46e40
commit 7e73a9a820
4 changed files with 111 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.shared
/**
* Phase 2 OIDC + API configuration shared by client and server (D-11, D-12).
*
* Hardcoded for the v1 single-environment per `PITFALLS.md` tech-debt acceptance:
* the homelab Authentik is the only target, so `BuildConfig`-style Gradle injection
* is deliberately deferred. The placeholder issuer host (`auth.example.invalid`) is
* substituted at deploy time by overriding the build at the call site, never by
* mutating this file in user installs.
*
* **Invariants the rest of Phase 2 depends on:**
* - `OIDC_ISSUER` ends with `/` so JWKS / authorization endpoints can append paths
* without double-slash bugs (PITFALLS.md #8 — Authentik is byte-sensitive here).
* - `OIDC_REDIRECT_URI` is exactly `recipe://callback` — both `iosApp/Info.plist`
* `CFBundleURLTypes` and the Android `<intent-filter>` registration must match
* byte-for-byte (D-09).
* - `OIDC_CLIENT_ID` doubles as the JWT `aud` claim value (D-07: single string,
* not array). Server-side `withAudience(OIDC_CLIENT_ID)` validates against it.
*/
public object Constants {
/** Reserved by `composeApp/server` for the local Ktor port (Phase 1). */
public const val SERVER_PORT: Int = 8080
/**
* Base URL the client uses for `/api/v1/...` calls. v1 single environment;
* staging support is deferred per PITFALLS.md tech-debt acceptance.
*/
public const val API_BASE_URL: String = "http://localhost:8080/"
/**
* Authentik OIDC issuer. Trailing slash is required (D-11, PITFALLS.md #8).
* Replace `auth.example.invalid` with the homelab Authentik hostname before
* shipping a build; the placeholder keeps tests/CI deterministic without
* leaking real infrastructure into the repo.
*/
public const val OIDC_ISSUER: String = "https://auth.example.invalid/application/o/recipe/"
/**
* OAuth2 client_id registered with Authentik. The same value MUST appear as
* the single-string `aud` claim per D-07.
*/
public const val OIDC_CLIENT_ID: String = "recipe-app"
/**
* Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte
* for byte. PKCE S256 + AppAuth state (D-05) make custom-scheme interception
* non-exploitable; Universal Links / App Links are explicitly deferred.
*/
public const val OIDC_REDIRECT_URI: String = "recipe://callback"
}

View File

@@ -0,0 +1,33 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Wire-format DTO for `GET /api/v1/me` (D-27).
*
* Mirrors [User] verbatim today; kept as a separate type so Phase 3 can extend the
* response with `householdId` (and any future fields) without changing the client
* domain model. Decoders MUST be configured with `ignoreUnknownKeys = true` so a
* Phase 2 client keeps parsing Phase 3+ responses (see
* `MeResponseSerializationTest.decoder ignores future fields like householdId without failing`).
*
* Wire keys are camelCase to stay consistent with kotlinx.serialization defaults;
* `sub` is left raw because it's an opaque OIDC claim and renaming it on the wire
* is a recipe for drift.
*/
@Serializable
public data class MeResponse(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
) {
/** One-to-one mapping to the client domain [User]; no fallback / coercion. */
public fun toUser(): User =
User(
id = id,
sub = sub,
email = email,
displayName = displayName,
)
}

View File

@@ -0,0 +1,26 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Authenticated user identity shared by client and server (D-25, D-27).
*
* Phase 2 always emits `User` in the [dev.ulfrx.recipe.shared.dto.MeResponse] payload;
* Phase 3 will extend the response with a household lookup but `User` itself stays
* stable. `id` is a server-issued UUID serialized as a string so the shared module
* stays free of Exposed / SQLDelight / kotlin.uuid dependencies (D-19 / INFRA-06).
*
* Field semantics:
* - [id] — server primary key (`users.id` UUID per D-24).
* - [sub] — opaque OIDC subject claim from Authentik; the only stable identity key
* for JIT provisioning per D-25.
* - [email] — most recent `email` claim. May change between logins (D-25 upserts).
* - [displayName] — most recent `name` / `preferred_username` claim (D-25).
*/
@Serializable
public data class User(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
)