# Phase 10: Core Plugins - Research

**Researched:** 2026-02-05
**Domain:** User plugin (frontend authentication, registration, profiles) and Blog plugin (posts, categories, tags, WYSIWYG, listing components)
**Confidence:** HIGH

## Summary

This phase delivers the User and Blog plugins that demonstrate the complete SummerCMS plugin system. The User plugin provides frontend user registration, login/logout, profile management, and password reset. The Blog plugin provides blog post management with WYSIWYG editor, hierarchical categories, tags, and frontend listing components.

The research builds on established patterns from Phase 2 (Plugin System with ZIO lifecycle), Phase 3 (Component System with HTMX handlers), Phase 6 (Backend Authentication with Argon2id), and Phase 9 (Content Management). The WinterCMS reference implementation (Golem15.User and Golem15.Blog plugins) provides detailed patterns for models, components, and settings that must be adapted to ZIO/Scala.

Key technology decisions: **TinyMCE 7.x** for WYSIWYG editing (mature, widely integrated, MIT license for self-hosted), **jwt-scala 11.0.3** for frontend user session tokens (already in stack from Phase 6), **Password4j** for password hashing (already in stack), and **Session component pattern** for access control on frontend pages.

**Primary recommendation:** Implement User plugin first (registration, login, session, account, reset-password components), then Blog plugin (post model with WYSIWYG, categories with nested tree, tags, posts/post components). Use cookie-based sessions for frontend users (separate from admin JWT) with CSRF protection. Follow WinterCMS component property patterns adapted to YAML + ZIO.

## Standard Stack

The established libraries/tools for this domain:

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| jwt-scala | 11.0.3 | Frontend user JWT tokens | Already in stack from Phase 6 |
| Password4j | 1.8.4 | Password hashing (Argon2id) | Already in stack from Phase 6 |
| TinyMCE | 7.x | WYSIWYG blog post editor | Mature, MIT self-hosted, excellent plugin ecosystem |
| Pebble | 4.1.1 | Component templates | Already in stack from Phase 3/4 |
| Quill (DB) | 4.8.6 | Database queries | Already in stack |
| circe-yaml | 1.0.0 | Settings model YAML | Already in stack |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| slugify | 3.0.7 | URL slug generation | Post/category slug auto-generation |
| commonmark-java | 0.22.0 | Markdown parsing | Content rendering option (alongside HTML) |
| zio-cache | 0.2.3 | User session caching | Reduce DB lookups for session validation |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| TinyMCE | Quill.js | Quill lighter but TinyMCE has better plugin ecosystem |
| TinyMCE | CKEditor 5 | CKEditor requires license for self-hosted advanced features |
| Cookie sessions | Pure JWT | Pure JWT cannot be revoked; cookies enable server-side control |
| Slugify library | Hand-roll | Slug generation has edge cases (unicode, special chars) |

**Installation (Mill build.mill):**
```scala
def mvnDeps = Seq(
  // Existing deps from Phase 1-9...

  // Slug generation
  mvn"com.github.slugify:slugify:3.0.7",

  // Markdown support (optional content format)
  mvn"org.commonmark:commonmark:0.22.0"
)
```

**Frontend (package.json):**
```json
{
  "dependencies": {
    "tinymce": "^7.0.0"
  }
}
```

## Architecture Patterns

### Recommended Project Structure
```
plugins/
├── golem15/
│   ├── user/
│   │   ├── plugin.yaml              # Plugin manifest
│   │   ├── Plugin.scala             # Lifecycle, component/settings registration
│   │   ├── models/
│   │   │   ├── User.scala           # Frontend user model
│   │   │   ├── UserGroup.scala      # User groups (optional)
│   │   │   ├── Settings.scala       # Plugin settings model
│   │   │   └── Throttle.scala       # Login throttle tracking
│   │   ├── components/
│   │   │   ├── Session.scala        # Session/auth component
│   │   │   ├── Account.scala        # Registration/login/profile
│   │   │   └── ResetPassword.scala  # Password reset flow
│   │   ├── services/
│   │   │   ├── UserService.scala    # User CRUD operations
│   │   │   ├── AuthService.scala    # Authentication logic
│   │   │   └── MailService.scala    # Email notifications
│   │   └── resources/
│   │       ├── db/migration/        # User tables
│   │       └── views/               # Component templates
│   └── blog/
│       ├── plugin.yaml
│       ├── Plugin.scala
│       ├── models/
│       │   ├── Post.scala           # Blog post model
│       │   ├── Category.scala       # Hierarchical categories
│       │   ├── Tag.scala            # Tags (many-to-many)
│       │   └── Settings.scala       # Blog settings
│       ├── components/
│       │   ├── Posts.scala          # Post listing
│       │   ├── Post.scala           # Single post display
│       │   ├── Categories.scala     # Category listing
│       │   └── RelatedPosts.scala   # Related posts widget
│       ├── controllers/
│       │   ├── Posts.scala          # Admin post management
│       │   └── Categories.scala     # Admin category management
│       └── resources/
│           ├── db/migration/
│           └── views/
```

### Pattern 1: Frontend User Model (from Golem15.User)
**What:** User model for frontend authentication with activation, groups, and settings
**When to use:** All frontend user operations
**Example:**
```scala
// Source: Golem15.User User.php adapted to Scala/ZIO
case class FrontendUser(
  id: Long,
  email: String,
  passwordHash: String,
  name: String,
  surname: String,
  username: Option[String] = None,      // Optional per Settings
  avatarPath: Option[String] = None,
  isActivated: Boolean = false,
  activationCode: Option[String] = None,
  activatedAt: Option[Instant] = None,
  persistCode: Option[String] = None,   // "Remember me" token
  resetPasswordCode: Option[String] = None,
  lastLogin: Option[Instant] = None,
  lastSeen: Option[Instant] = None,
  createdIpAddress: Option[String] = None,
  lastIpAddress: Option[String] = None,
  isGuest: Boolean = false,
  isSuperuser: Boolean = false,
  deletedAt: Option[Instant] = None,    // Soft delete
  createdAt: Instant,
  updatedAt: Instant
)

object FrontendUser:
  // Validation rules (from WinterCMS User model)
  val validationRules = Map(
    "email" -> List(Required, Email, Unique("users", "email"), Between(6, 255)),
    "password" -> List(Required, Between(8, 255), Confirmed),
    "name" -> List(Required, Between(2, 100)),
    "surname" -> List(Required, Between(2, 100))
  )

  // Minimum password length from settings
  def minPasswordLength: ZIO[UserSettings, Nothing, Int] =
    ZIO.serviceWith[UserSettings](_.minPasswordLength.getOrElse(8))

// User service with ZIO
trait FrontendUserService:
  def findByEmail(email: String): IO[UserError, Option[FrontendUser]]
  def findById(id: Long): IO[UserError, Option[FrontendUser]]
  def register(data: RegistrationData): IO[UserError, FrontendUser]
  def authenticate(credentials: Credentials, remember: Boolean): IO[AuthError, FrontendUser]
  def activate(userId: Long, code: String): IO[ActivationError, FrontendUser]
  def requestPasswordReset(email: String): IO[UserError, Unit]
  def resetPassword(code: String, newPassword: String): IO[ResetError, FrontendUser]
  def updateProfile(userId: Long, data: ProfileUpdate): IO[UserError, FrontendUser]
  def updateAvatar(userId: Long, file: UploadedFile): IO[UserError, FrontendUser]
  def touchLastSeen(userId: Long): UIO[Unit]
  def touchIpAddress(userId: Long, ip: String): UIO[Unit]
  def isThrottled(ip: String): UIO[Boolean]
```

### Pattern 2: User Settings Model (from Golem15.User Settings)
**What:** Plugin settings stored in database, YAML-defined fields
**When to use:** Configurable plugin behavior
**Example:**
```scala
// Source: Golem15.User Settings.php adapted to Scala
sealed trait ActivateMode
object ActivateMode:
  case object Auto extends ActivateMode   // Automatic activation
  case object User extends ActivateMode   // User email verification
  case object Admin extends ActivateMode  // Admin approval required

sealed trait RememberLogin
object RememberLogin:
  case object Always extends RememberLogin
  case object Never extends RememberLogin
  case object Ask extends RememberLogin

case class UserPluginSettings(
  allowRegistration: Boolean = true,
  requireActivation: Boolean = true,
  activateMode: ActivateMode = ActivateMode.Auto,
  useThrottle: Boolean = true,
  useRegisterThrottle: Boolean = true,
  blockPersistence: Boolean = false,     // Prevent concurrent sessions
  rememberLogin: RememberLogin = RememberLogin.Always,
  minPasswordLength: Int = 8
)

// Settings YAML (models/settings/fields.yaml)
// allow_registration:
//   type: checkbox
//   label: golem15.user::lang.settings.allow_registration
//   default: true
//   comment: Allow new users to register
//
// activate_mode:
//   type: dropdown
//   label: golem15.user::lang.settings.activate_mode
//   options:
//     auto: Automatic activation
//     user: User activates via email
//     admin: Administrator approval
//   default: auto

// Settings service
trait UserSettingsService:
  def get: UIO[UserPluginSettings]
  def update(settings: UserPluginSettings): IO[SettingsError, Unit]

object UserSettingsService:
  val live: ZLayer[SettingsRepository, Nothing, UserSettingsService] =
    ZLayer.fromFunction { (repo: SettingsRepository) =>
      new UserSettingsService:
        def get: UIO[UserPluginSettings] =
          repo.get("user_settings")
            .map(_.map(parseSettings).getOrElse(UserPluginSettings()))
            .orElseSucceed(UserPluginSettings())
    }
```

### Pattern 3: Session Component (from Golem15.User Session)
**What:** Component that controls page access and injects user context
**When to use:** All pages requiring authentication or guest-only access
**Example:**
```scala
// Source: Golem15.User Session.php adapted to ZIO component
sealed trait SecurityMode
object SecurityMode:
  case object All extends SecurityMode      // Allow everyone
  case object User extends SecurityMode     // Logged-in users only
  case object Guest extends SecurityMode    // Non-logged-in only

class SessionComponent(
  val alias: String,
  val properties: Ref[Map[String, PropertyValue]],
  val pageContext: PageContext,
  userService: FrontendUserService,
  authService: AuthService
) extends SummerComponent:

  override val details = ComponentDetails(
    name = "Session",
    description = "User session management and access control"
  )

  override val propertySchema = List(
    PropertyDef("security", PropertyType.Dropdown,
      title = "Access Control",
      options = Map("all" -> "All visitors", "user" -> "Logged-in users", "guest" -> "Guests only"),
      default = "all"),
    PropertyDef("allowedUserGroups", PropertyType.Set,
      title = "Allowed User Groups",
      description = "Restrict to specific user groups"),
    PropertyDef("redirect", PropertyType.Dropdown,
      title = "Redirect Page",
      description = "Page to redirect unauthorized visitors")
  )

  // Page variable: user
  private var currentUser: Option[FrontendUser] = None

  override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
    for
      props    <- properties.get
      security = props.get("security").map(_.asString).getOrElse("all")
      allowed  <- checkUserSecurity(security, props)
      _        <- ZIO.when(!allowed) {
        val redirectPage = props.get("redirect").map(_.asString)
        redirectPage match
          case Some(page) if page.nonEmpty =>
            ZIO.fail(ComponentRedirect(page))
          case _ =>
            ZIO.fail(ComponentError.AccessDenied("User not authorized for this page"))
      }
      user     <- authService.getCurrentUser
      _        <- user.fold(ZIO.unit)(u => userService.touchLastSeen(u.id))
    yield
      currentUser = user
      pageContext.set("user", user.orNull)

  private def checkUserSecurity(
    security: String,
    props: Map[String, PropertyValue]
  ): ZIO[ComponentEnv, ComponentError, Boolean] =
    for
      isAuthenticated <- authService.isAuthenticated
      allowedGroups   = props.get("allowedUserGroups").map(_.asStringList).getOrElse(Nil)
      result <- security match
        case "all" => ZIO.succeed(true)
        case "guest" => ZIO.succeed(!isAuthenticated)
        case "user" =>
          if !isAuthenticated then ZIO.succeed(false)
          else if allowedGroups.isEmpty then ZIO.succeed(true)
          else
            authService.getCurrentUser.map { user =>
              user.exists(u => allowedGroups.exists(g => u.groups.contains(g)))
            }
    yield result

  // HTMX handler: Logout
  def onLogout: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      _     <- authService.logout
      props <- properties.get
      redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
    yield HtmxResponse(
      html = Html.empty,
      triggers = Map("userLoggedOut" -> "true"),
      headers = Map("HX-Redirect" -> redirectUrl)
    )

  def user: Option[FrontendUser] = currentUser
```

### Pattern 4: Account Component (from Golem15.User Account)
**What:** Combined registration, login, and profile management component
**When to use:** Account management pages
**Example:**
```scala
// Source: Golem15.User Account.php adapted to ZIO
class AccountComponent(
  val alias: String,
  val properties: Ref[Map[String, PropertyValue]],
  val pageContext: PageContext,
  userService: FrontendUserService,
  authService: AuthService,
  mailService: MailService,
  settingsService: UserSettingsService
) extends SummerComponent:

  override val propertySchema = List(
    PropertyDef("redirect", PropertyType.Dropdown,
      title = "Redirect After",
      description = "Page to redirect after login/register"),
    PropertyDef("paramCode", PropertyType.String,
      title = "Activation Code Param",
      default = "code"),
    PropertyDef("requirePassword", PropertyType.Checkbox,
      title = "Require Password for Update",
      description = "Require current password when updating profile")
  )

  // HTMX handler: Sign in
  def onSignin: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      request  <- ZIO.service[Request]
      settings <- settingsService.get
      login    <- getFormValue("login").map(_.trim)
      password <- getFormValue("password")
      remember <- getFormValue("remember").map(_.contains("true"))

      // Validate input
      _ <- ZIO.when(login.isEmpty) {
        ZIO.fail(ValidationError("login", "Email is required"))
      }
      _ <- ZIO.when(password.length < settings.minPasswordLength) {
        ZIO.fail(ValidationError("password", s"Password must be at least ${settings.minPasswordLength} characters"))
      }

      // Authenticate
      credentials = Credentials(login, password)
      user <- authService.authenticate(credentials, remember)
        .mapError {
          case AuthError.InvalidCredentials =>
            // Generic message to prevent enumeration
            ValidationError("login", "Invalid email or password")
          case AuthError.AccountBanned =>
            ValidationError("login", "Account is banned")
          case AuthError.AccountNotActivated =>
            ValidationError("login", "Account not activated")
        }

      // Record IP address
      ip = request.remoteAddress.map(_.toString).getOrElse("")
      _ <- userService.touchIpAddress(user.id, ip)

      // Redirect
      props       <- properties.get
      redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
    yield HtmxResponse(
      html = Html.empty,
      triggers = Map("userLoggedIn" -> user.id.toString),
      headers = Map("HX-Redirect" -> redirectUrl)
    )

  // HTMX handler: Register
  def onRegister: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      settings <- settingsService.get
      _        <- ZIO.when(!settings.allowRegistration) {
        ZIO.fail(ComponentError.Forbidden("Registration is disabled"))
      }

      request <- ZIO.service[Request]
      ip      = request.remoteAddress.map(_.toString).getOrElse("")

      // Check throttle
      throttled <- userService.isThrottled(ip)
      _         <- ZIO.when(throttled && settings.useRegisterThrottle) {
        ZIO.fail(ComponentError.RateLimited("Registration is throttled"))
      }

      // Parse form data
      data <- parseRegistrationForm

      // Register user
      requireActivation = settings.requireActivation
      autoActivate = settings.activateMode == ActivateMode.Auto
      user <- userService.register(data.copy(
        createdIpAddress = Some(ip),
        isActivated = autoActivate || !requireActivation
      ))

      // Send activation email if needed
      _ <- ZIO.when(settings.activateMode == ActivateMode.User) {
        for
          code <- generateActivationCode(user.id)
          _    <- mailService.sendActivationEmail(user, code)
        yield ()
      }

      // Auto-login if activated
      _ <- ZIO.when(autoActivate || !requireActivation) {
        authService.login(user, remember = false)
      }

      props       <- properties.get
      redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
    yield HtmxResponse(
      html = Html.empty,
      triggers = Map("userRegistered" -> user.id.toString),
      headers = Map("HX-Redirect" -> redirectUrl)
    )

  // HTMX handler: Update profile
  def onUpdate: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      user <- authService.getCurrentUser.someOrFail(
        ComponentError.Unauthorized("Not logged in")
      )
      props <- properties.get
      requirePassword = props.get("requirePassword").exists(_.asBoolean)

      // Validate current password if required
      _ <- ZIO.when(requirePassword) {
        for
          currentPass <- getFormValue("password_current")
          valid       <- authService.verifyPassword(user.id, currentPass)
          _           <- ZIO.when(!valid) {
            ZIO.fail(ValidationError("password_current", "Invalid current password"))
          }
        yield ()
      }

      // Parse and validate update data
      update <- parseProfileUpdate

      // Handle avatar upload
      avatarFile <- getUploadedFile("avatar")
      _          <- avatarFile.fold(ZIO.unit) { file =>
        userService.updateAvatar(user.id, file)
      }

      // Update user
      updated <- userService.updateProfile(user.id, update)

      // If password changed, re-authenticate
      _ <- ZIO.when(update.newPassword.isDefined) {
        authService.login(updated, remember = true)
      }
    yield HtmxResponse(
      html = renderPartial("success", Map("message" -> "Profile updated successfully")),
      triggers = Map("profileUpdated" -> "true")
    )
```

### Pattern 5: Blog Post Model (from Golem15.Blog Post)
**What:** Blog post with content, categories, tags, featured images, and publishing state
**When to use:** All blog functionality
**Example:**
```scala
// Source: Golem15.Blog Post.php adapted to Scala
case class BlogPost(
  id: Long,
  authorId: Long,               // Backend user who created
  title: String,
  slug: String,
  excerpt: Option[String] = None,
  content: String,              // Markdown or HTML
  contentHtml: String,          // Rendered HTML
  charactersCount: Int = 0,
  published: Boolean = false,
  publishedAt: Option[Instant] = None,
  isFeatured: Boolean = false,
  mainCategoryId: Option[Long] = None,
  metadata: Map[String, Json] = Map.empty,
  createdAt: Instant,
  updatedAt: Instant
)

// Relations
case class BlogPostWithRelations(
  post: BlogPost,
  categories: List[Category],
  tags: List[Tag],
  featuredImages: List[MediaItem],
  author: Option[BackendUser]
)

object BlogPost:
  val validationRules = Map(
    "title" -> List(Required),
    "slug" -> List(Required, Regex("^[a-z0-9\\-]+$"), Unique("blog_posts", "slug")),
    "content" -> List(Required)
  )

  // Allowed sorting options (from WinterCMS)
  val allowedSortingOptions = Map(
    "title asc" -> "Title (A-Z)",
    "title desc" -> "Title (Z-A)",
    "created_at asc" -> "Created (oldest)",
    "created_at desc" -> "Created (newest)",
    "published_at asc" -> "Published (oldest)",
    "published_at desc" -> "Published (newest)",
    "random" -> "Random"
  )

  // Format content to HTML (before save)
  def formatHtml(content: String): String =
    val parser = Parser.builder().build()
    val document = parser.parse(content)
    val renderer = HtmlRenderer.builder().build()
    val html = renderer.render(document)
    // Sanitize to prevent XSS (allow safe tags only)
    Jsoup.clean(html, Safelist.relaxed())

  // Generate summary from content
  def generateSummary(contentHtml: String, maxLength: Int = 600): String =
    val moreMarker = "<!-- more -->"
    if contentHtml.contains(moreMarker) then
      contentHtml.split(moreMarker).headOption.getOrElse("")
    else
      Jsoup.parse(contentHtml).text().take(maxLength) + "..."

trait BlogPostService:
  def create(data: PostCreateData): IO[PostError, BlogPost]
  def update(id: Long, data: PostUpdateData): IO[PostError, BlogPost]
  def delete(id: Long): IO[PostError, Unit]
  def findBySlug(slug: String): IO[PostError, Option[BlogPostWithRelations]]
  def listFrontend(options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
  def listByCategory(categoryId: Long, options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
  def listByTag(tagSlug: String, options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
  def getRelatedPosts(postId: Long, limit: Int): IO[PostError, List[BlogPostWithRelations]]

case class PostListOptions(
  page: Int = 1,
  perPage: Int = 10,
  sort: String = "published_at desc",
  published: Boolean = true,
  exceptPost: Option[List[String]] = None,
  exceptCategories: Option[List[String]] = None,
  search: Option[String] = None
)
```

### Pattern 6: Hierarchical Category Model (from Golem15.Blog Category)
**What:** Blog categories with nested tree structure (parent/child relationships)
**When to use:** Category organization
**Example:**
```scala
// Source: Golem15.Blog Category.php with NestedTree trait
case class Category(
  id: Long,
  name: String,
  slug: String,
  description: Option[String] = None,
  parentId: Option[Long] = None,
  nestLeft: Int = 0,              // Nested set model
  nestRight: Int = 0,
  nestDepth: Int = 0,
  published: Boolean = true,
  publishedAt: Option[Instant] = None,
  createdAt: Instant,
  updatedAt: Instant
)

trait CategoryService:
  def create(data: CategoryData): IO[CategoryError, Category]
  def update(id: Long, data: CategoryData): IO[CategoryError, Category]
  def delete(id: Long): IO[CategoryError, Unit]
  def findBySlug(slug: String): IO[CategoryError, Option[Category]]
  def listAll: UIO[List[Category]]
  def getTree: UIO[List[CategoryTreeNode]]
  def getChildren(parentId: Long): UIO[List[Category]]
  def getAllChildrenAndSelf(categoryId: Long): UIO[List[Category]]
  def getPostCount(categoryId: Long): UIO[Int]
  def getNestedPostCount(categoryId: Long): UIO[Int]

case class CategoryTreeNode(
  category: Category,
  children: List[CategoryTreeNode],
  postCount: Int
)

// Nested tree operations
object NestedTree:
  // Rebuild left/right values after insert/delete
  def rebuild(categories: List[Category]): List[Category] = ???

  // Get all descendants
  def descendants(category: Category, all: List[Category]): List[Category] =
    all.filter(c => c.nestLeft > category.nestLeft && c.nestRight < category.nestRight)

  // Get ancestors
  def ancestors(category: Category, all: List[Category]): List[Category] =
    all.filter(c => c.nestLeft < category.nestLeft && c.nestRight > category.nestRight)
```

### Pattern 7: Tag System (Separate from Categories)
**What:** Tags for free-form labeling, separate from hierarchical categories
**When to use:** Flexible post organization
**Example:**
```scala
// Source: CONTEXT.md - tags separate from categories
case class Tag(
  id: Long,
  name: String,
  slug: String,
  createdAt: Instant,
  updatedAt: Instant
)

// Many-to-many pivot table
case class PostTag(
  postId: Long,
  tagId: Long
)

trait TagService:
  def findOrCreate(name: String): IO[TagError, Tag]
  def findBySlug(slug: String): IO[TagError, Option[Tag]]
  def listAll: UIO[List[Tag]]
  def listForPost(postId: Long): UIO[List[Tag]]
  def getPopularTags(limit: Int): UIO[List[TagWithCount]]
  def syncPostTags(postId: Long, tagNames: List[String]): IO[TagError, List[Tag]]

case class TagWithCount(tag: Tag, postCount: Int)
```

### Pattern 8: Posts Component (from Golem15.Blog Posts)
**What:** Frontend component for displaying blog post listings with pagination
**When to use:** Blog index, category pages, archive pages
**Example:**
```scala
// Source: Golem15.Blog Posts.php adapted to ZIO
class PostsComponent(
  val alias: String,
  val properties: Ref[Map[String, PropertyValue]],
  val pageContext: PageContext,
  postService: BlogPostService,
  categoryService: CategoryService
) extends SummerComponent:

  override val propertySchema = List(
    PropertyDef("pageNumber", PropertyType.String,
      title = "Page Number Parameter",
      default = "{{ :page }}"),
    PropertyDef("categoryFilter", PropertyType.String,
      title = "Category Filter",
      description = "Filter by category slug"),
    PropertyDef("postsPerPage", PropertyType.String,
      title = "Posts Per Page",
      default = "10",
      validation = ValidationRules.integer(min = 1, max = 100)),
    PropertyDef("noPostsMessage", PropertyType.String,
      title = "No Posts Message",
      default = "No posts found."),
    PropertyDef("sortOrder", PropertyType.Dropdown,
      title = "Sort Order",
      options = BlogPost.allowedSortingOptions,
      default = "published_at desc"),
    PropertyDef("throwNotFound", PropertyType.Checkbox,
      title = "Throw 404 on Empty",
      default = "false")
  )

  // Page variables
  private var posts: PaginatedResult[BlogPostWithRelations] = _
  private var category: Option[Category] = None
  private var pageParam: String = "page"

  override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
    for
      props <- properties.get

      // Load category if filtered
      catSlug = props.get("categoryFilter").map(_.asString).filter(_.nonEmpty)
      cat <- catSlug.fold(ZIO.succeed(None))(slug =>
        categoryService.findBySlug(slug).map(Some(_))
      )
      _ <- ZIO.when(catSlug.isDefined && cat.isEmpty) {
        ZIO.fail(ComponentError.NotFound("Category not found"))
      }

      // Build list options
      perPage = props.get("postsPerPage").flatMap(_.asInt).getOrElse(10)
      sort = props.get("sortOrder").map(_.asString).getOrElse("published_at desc")
      page = pageContext.param("page").flatMap(_.toIntOption).getOrElse(1)

      options = PostListOptions(
        page = page,
        perPage = perPage,
        sort = sort,
        published = !pageContext.isBackendEditor
      )

      // Fetch posts
      result <- cat match
        case Some(c) => postService.listByCategory(c.id, options)
        case None => postService.listFrontend(options)

      // Handle empty results
      throwNotFound = props.get("throwNotFound").exists(_.asBoolean)
      _ <- ZIO.when(result.items.isEmpty && throwNotFound) {
        ZIO.fail(ComponentError.NotFound("No posts found"))
      }
    yield
      posts = result
      category = cat
      pageParam = "page"
      pageContext.set("posts", posts.items.asJava)
      pageContext.set("category", category.orNull)
      pageContext.set("noPostsMessage", props.get("noPostsMessage").map(_.asString).getOrElse("No posts found."))

  // HTMX handler: Load more (infinite scroll)
  def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      page  <- getFormValue("page").map(_.toIntOption.getOrElse(1))
      props <- properties.get
      perPage = props.get("postsPerPage").flatMap(_.asInt).getOrElse(10)
      sort = props.get("sortOrder").map(_.asString).getOrElse("published_at desc")

      options = PostListOptions(page = page, perPage = perPage, sort = sort)
      result <- postService.listFrontend(options)
      html   <- renderPartial("items", Map("posts" -> result.items.asJava))
    yield HtmxResponse(
      html = html,
      triggers = if !result.hasMore then Map("postsExhausted" -> "true") else Map.empty
    )

  def getPosts: PaginatedResult[BlogPostWithRelations] = posts
  def getCategory: Option[Category] = category
```

### Pattern 9: WYSIWYG Editor Integration (TinyMCE)
**What:** Rich text editing for blog posts in admin
**When to use:** Post create/edit forms
**Example:**
```javascript
// Source: TinyMCE documentation + CONTEXT.md decisions
// Admin form widget for blog post content

// Initialize TinyMCE for blog content field
function initBlogEditor(selector) {
  tinymce.init({
    selector: selector,
    height: 500,
    plugins: [
      'anchor', 'autolink', 'charmap', 'codesample', 'emoticons',
      'image', 'link', 'lists', 'media', 'searchreplace', 'table',
      'visualblocks', 'wordcount', 'fullscreen', 'preview'
    ],
    toolbar: 'undo redo | blocks fontfamily fontsize | ' +
             'bold italic underline strikethrough | link image media table | ' +
             'align lineheight | numlist bullist indent outdent | ' +
             'emoticons charmap | removeformat | fullscreen preview',

    // Image upload handler
    images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
      const formData = new FormData();
      formData.append('file', blobInfo.blob(), blobInfo.filename());

      htmx.ajax('POST', '/admin/media/upload', {
        values: formData,
        target: 'body',
        swap: 'none'
      }).then(response => {
        resolve(response.url);
      }).catch(error => {
        reject('Image upload failed');
      });
    }),

    // Content style
    content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; }',

    // HTMX integration - update hidden field on change
    setup: (editor) => {
      editor.on('change', () => {
        editor.save(); // Sync to textarea
        // Trigger HTMX form validation if needed
        htmx.trigger(editor.getElement(), 'change');
      });
    }
  });
}

// Usage in admin form
document.addEventListener('DOMContentLoaded', () => {
  initBlogEditor('#Form-field-Post-content');
});
```

### Pattern 10: Frontend Authentication Flow
**What:** Cookie-based session for frontend users with CSRF protection
**When to use:** All authenticated frontend requests
**Example:**
```scala
// Source: Phase 6 research + ZIO HTTP cookies
trait FrontendAuthService:
  def login(user: FrontendUser, remember: Boolean): IO[AuthError, String]
  def logout: UIO[Unit]
  def getCurrentUser: UIO[Option[FrontendUser]]
  def isAuthenticated: UIO[Boolean]
  def verifyPassword(userId: Long, password: String): IO[AuthError, Boolean]
  def generateSessionToken(userId: Long, remember: Boolean): IO[AuthError, String]

object FrontendAuthService:
  val live: ZLayer[
    FrontendUserRepository & PasswordService & Config,
    Nothing,
    FrontendAuthService
  ] = ZLayer.fromZIO {
    for
      userRepo <- ZIO.service[FrontendUserRepository]
      password <- ZIO.service[PasswordService]
      config   <- ZIO.service[Config]
      session  <- Ref.make[Option[FrontendUser]](None)
    yield new FrontendAuthService:
      private val sessionCookieName = "summer_session"
      private val rememberCookieName = "summer_remember"

      def login(user: FrontendUser, remember: Boolean): IO[AuthError, String] =
        for
          token <- generateSessionToken(user.id, remember)
          _     <- session.set(Some(user))
        yield token

      def generateSessionToken(userId: Long, remember: Boolean): IO[AuthError, String] =
        val duration = if remember then config.rememberDuration else config.sessionDuration
        val expiry = Instant.now.plus(duration)
        val claims = JwtClaim(
          subject = Some(userId.toString),
          expiration = Some(expiry.getEpochSecond),
          issuedAt = Some(Instant.now.getEpochSecond)
        )
        ZIO.succeed(Jwt.encode(claims, config.jwtSecret, JwtAlgorithm.HS256))

      def getCurrentUser: UIO[Option[FrontendUser]] =
        session.get

// Cookie configuration
object FrontendCookieConfig:
  def sessionCookie(token: String, remember: Boolean, config: Config): Cookie.Response =
    Cookie.Response(
      name = "summer_session",
      content = token,
      maxAge = Some(if remember then config.rememberDuration else config.sessionDuration),
      path = Some(Path.root),
      isSecure = config.isProduction,
      isHttpOnly = true,
      sameSite = Some(Cookie.SameSite.Lax)  // Lax for frontend to allow navigation
    )

// Auth middleware for frontend
val frontendAuthMiddleware: HandlerAspect[FrontendAuthService, Unit] =
  HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request =>
    for
      authService <- ZIO.service[FrontendAuthService]
      token       <- ZIO.fromOption(
        request.cookie("summer_session").map(_.content)
      ).orElse(ZIO.succeed(""))
      _           <- authService.validateAndSetSession(token).ignore
    yield (request, ())
  })
```

### Anti-Patterns to Avoid
- **Storing plain passwords:** Always use Argon2id via Password4j
- **Revealing email existence:** Login errors should be generic
- **Direct user input in templates:** Always escape, use Pebble autoescaping
- **Skipping CSRF on HTMX POSTs:** All mutations need CSRF validation
- **Nested tree without rebuild:** Category operations must rebuild left/right
- **Publishing without published_at:** Published posts must have a date
- **Blocking in components:** All operations must be ZIO effects
- **Hard-coded redirects:** Use component properties for redirect URLs

## Don't Hand-Roll

Problems that look simple but have existing solutions:

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Password hashing | Custom hash | Password4j Argon2id | Timing attacks, salt handling, cost tuning |
| WYSIWYG editor | Custom textarea | TinyMCE | XSS sanitization, image upload, formatting |
| URL slugs | Simple replace | Slugify library | Unicode handling, transliteration, edge cases |
| Nested categories | Manual tree | Nested set model | Efficient subtree queries, ordering |
| Email templates | String concat | Pebble templates | Escaping, i18n, layout inheritance |
| Session tokens | UUID only | JWT with claims | Expiry, user context, revocation tracking |
| Markdown rendering | Regex | CommonMark parser | Edge cases, security, spec compliance |
| Activation codes | Random string | Secure random + hash | Timing attacks, entropy |

**Key insight:** User authentication and CMS content have many security and UX edge cases. The WinterCMS reference implementation handles these; adapt patterns, don't reinvent.

## Common Pitfalls

### Pitfall 1: User Enumeration via Login
**What goes wrong:** Attacker determines which emails have accounts
**Why it happens:** Different error messages for "user not found" vs "wrong password"
**How to avoid:** Always return generic "Invalid email or password" message
**Warning signs:** Different response times for existing vs non-existing emails

### Pitfall 2: Activation Code Timing Attack
**What goes wrong:** Attacker brute-forces activation codes faster
**Why it happens:** Early return when code not found vs wrong code
**How to avoid:** Always perform comparison even for non-existent users
**Warning signs:** Response time varies with code validity

### Pitfall 3: Session Fixation on Login
**What goes wrong:** Attacker pre-sets session ID, hijacks after login
**Why it happens:** Reusing session token after authentication
**How to avoid:** Generate new session token on login
**Warning signs:** Same cookie value before and after login

### Pitfall 4: XSS in Blog Content
**What goes wrong:** Malicious script executes in blog post
**Why it happens:** Raw HTML allowed without sanitization
**How to avoid:** Sanitize HTML with allow-list (Jsoup Safelist.relaxed())
**Warning signs:** Script tags or event handlers in content

### Pitfall 5: Nested Tree Corruption
**What goes wrong:** Categories display in wrong order or missing
**Why it happens:** Left/right values not rebuilt after insert/delete
**How to avoid:** Rebuild tree on every structural change
**Warning signs:** Gaps in left/right sequence, overlapping ranges

### Pitfall 6: Published State Bypass
**What goes wrong:** Unpublished posts visible on frontend
**Why it happens:** Missing `published=true AND published_at <= NOW()` filter
**How to avoid:** Always apply publication filter in frontend queries
**Warning signs:** Draft posts appearing in listings

### Pitfall 7: Remember Me Token Reuse
**What goes wrong:** Logged out user can still access with old cookie
**Why it happens:** Token not invalidated on logout
**How to avoid:** Rotate persist_code on logout, track in database
**Warning signs:** "Logout" doesn't actually prevent access

## Code Examples

Verified patterns from official sources:

### Database Schema (Flyway Migration)
```sql
-- Source: Golem15.User + Golem15.Blog adapted for Scala
-- V10.1__user_plugin.sql

CREATE TABLE frontend_users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    name VARCHAR(100) NOT NULL,
    surname VARCHAR(100) NOT NULL,
    username VARCHAR(255) UNIQUE,
    avatar_path VARCHAR(255),
    is_activated BOOLEAN DEFAULT false,
    activation_code VARCHAR(64),
    activated_at TIMESTAMPTZ,
    persist_code VARCHAR(64),          -- Remember me token
    reset_password_code VARCHAR(64),
    last_login TIMESTAMPTZ,
    last_seen TIMESTAMPTZ,
    created_ip_address INET,
    last_ip_address INET,
    is_guest BOOLEAN DEFAULT false,
    is_superuser BOOLEAN DEFAULT false,
    deleted_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_frontend_users_email ON frontend_users(email);
CREATE INDEX idx_frontend_users_username ON frontend_users(username);
CREATE INDEX idx_frontend_users_persist_code ON frontend_users(persist_code);

CREATE TABLE user_groups (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    code VARCHAR(64) NOT NULL UNIQUE,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE users_groups (
    user_id BIGINT NOT NULL REFERENCES frontend_users(id) ON DELETE CASCADE,
    group_id BIGINT NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, group_id)
);

CREATE TABLE user_throttle (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT REFERENCES frontend_users(id) ON DELETE CASCADE,
    ip_address INET,
    attempts INT DEFAULT 0,
    last_attempt_at TIMESTAMPTZ,
    is_suspended BOOLEAN DEFAULT false,
    suspended_at TIMESTAMPTZ,
    is_banned BOOLEAN DEFAULT false,
    banned_at TIMESTAMPTZ
);

CREATE INDEX idx_user_throttle_user ON user_throttle(user_id);
CREATE INDEX idx_user_throttle_ip ON user_throttle(ip_address);

-- V10.2__blog_plugin.sql

CREATE TABLE blog_posts (
    id BIGSERIAL PRIMARY KEY,
    author_id BIGINT NOT NULL REFERENCES backend_users(id),
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    excerpt TEXT,
    content TEXT NOT NULL,
    content_html TEXT NOT NULL,
    characters_count INT DEFAULT 0,
    published BOOLEAN DEFAULT false,
    published_at TIMESTAMPTZ,
    is_featured BOOLEAN DEFAULT false,
    main_category_id BIGINT REFERENCES blog_categories(id),
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_posts_published ON blog_posts(published, published_at);
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);

CREATE TABLE blog_categories (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    description TEXT,
    parent_id BIGINT REFERENCES blog_categories(id),
    nest_left INT DEFAULT 0,
    nest_right INT DEFAULT 0,
    nest_depth INT DEFAULT 0,
    published BOOLEAN DEFAULT true,
    published_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_blog_categories_slug ON blog_categories(slug);
CREATE INDEX idx_blog_categories_nested ON blog_categories(nest_left, nest_right);

CREATE TABLE blog_tags (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_blog_tags_slug ON blog_tags(slug);

CREATE TABLE blog_posts_categories (
    post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
    category_id BIGINT NOT NULL REFERENCES blog_categories(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, category_id)
);

CREATE TABLE blog_posts_tags (
    post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
    tag_id BIGINT NOT NULL REFERENCES blog_tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);
```

### Component Template Example
```html
{# plugins/golem15/blog/components/posts/default.htm #}
{# Source: Golem15.Blog Posts component pattern #}
<div class="blog-posts" id="posts-{{ __SELF__ }}">
  {% if posts is empty %}
    <div class="no-posts">
      {{ noPostsMessage }}
    </div>
  {% else %}
    <div class="posts-list">
      {% for post in posts %}
        {% include "posts/item" %}
      {% endfor %}
    </div>

    {% if posts.hasNext %}
      <div class="load-more">
        <button
          hx-post="{{ componentHandler('onLoadMore') }}"
          hx-vals='{"page": {{ posts.currentPage + 1 }}}'
          hx-target="#posts-{{ __SELF__ }} .posts-list"
          hx-swap="beforeend"
          hx-indicator=".htmx-indicator"
          class="btn btn-primary">
          {{ 'Load More'|_ }}
          <span class="htmx-indicator">Loading...</span>
        </button>
      </div>
    {% endif %}
  {% endif %}
</div>
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Server sessions only | JWT + cookies hybrid | 2020+ | Scalability with revocation |
| MD5/SHA1 passwords | Argon2id | OWASP 2022+ | GPU resistance |
| Custom WYSIWYG | TinyMCE/CKEditor | Always | Security, features |
| Flat categories | Nested set model | Established | Efficient tree queries |
| Manual pagination | Infinite scroll option | 2023+ | Better mobile UX |
| Callback handlers | HTMX + ZIO effects | 2024+ | Simpler state management |

**Deprecated/outdated:**
- MD5/SHA1 for passwords: Never use, no resistance to cracking
- jQuery AJAX: Use HTMX data attributes
- Storing passwords encrypted (not hashed): Use one-way hashing
- Session-only auth without remember: Poor UX

## Open Questions

Things that couldn't be fully resolved:

1. **Avatar Storage Location**
   - What we know: Phase 9 has media library with storage abstraction
   - What's unclear: Should user avatars use media library or separate user files?
   - Recommendation: Use media library for consistency, path in user record

2. **Reading Time Calculation**
   - What we know: CONTEXT.md says "auto-calculated"
   - What's unclear: Words per minute rate, include code blocks?
   - Recommendation: 200 WPM average, exclude code blocks, round to nearest minute

3. **Related Posts Algorithm**
   - What we know: "Automatic by shared categories/tags"
   - What's unclear: Weighting between categories vs tags, minimum threshold
   - Recommendation: Score = 2*shared_categories + 1*shared_tags, exclude current post, limit 3-5

4. **Social Share Buttons**
   - What we know: OpenGraph meta tags + share buttons component
   - What's unclear: Which platforms? Native share API?
   - Recommendation: Copy link, Twitter, Facebook, LinkedIn; use Web Share API if available

5. **Archive Pages by Year/Month**
   - What we know: CONTEXT.md marks as Claude's discretion
   - What's unclear: Whether to implement in Phase 10 or defer
   - Recommendation: Include basic archive routes, full archive component can be Phase 11+

## Sources

### Primary (HIGH confidence)
- [Golem15.User Plugin](golem15-wintercms-starter/plugins/golem15/user/) - Reference implementation
- [Golem15.Blog Plugin](golem15-wintercms-starter/plugins/golem15/blog/) - Reference implementation
- [ZIO HTTP Authentication](https://ziohttp.com/examples/authentication) - JWT patterns
- [ZIO HTTP Cookies](https://zio.dev/zio-http/reference/headers/session/cookies/) - Cookie management
- [Password4j](https://github.com/Password4j/password4j) - Already in stack
- [TinyMCE Documentation](https://www.tiny.cloud/docs/tinymce/latest/) - WYSIWYG integration

### Secondary (MEDIUM confidence)
- [HTMX Async Auth Pattern](https://htmx.org/examples/async-auth/) - Frontend auth with HTMX
- [HTMX Web Security](https://htmx.org/essays/web-security-basics-with-htmx/) - CSRF patterns
- [Liveblocks Editor Comparison](https://liveblocks.io/blog/which-rich-text-editor-framework-should-you-choose-in-2025) - Editor selection rationale
- [Nested Set Model](https://en.wikipedia.org/wiki/Nested_set_model) - Category tree structure
- [OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Security guidance

### Tertiary (LOW confidence)
- Community patterns for Scala CMS implementations
- General blog architecture recommendations

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH - All libraries already in stack or well-documented
- User plugin patterns: HIGH - Direct adaptation of Golem15.User reference
- Blog plugin patterns: HIGH - Direct adaptation of Golem15.Blog reference
- WYSIWYG integration: MEDIUM - TinyMCE documented but specific integration needs validation
- Component patterns: HIGH - Based on Phase 3 research

**Research date:** 2026-02-05
**Valid until:** 2026-03-05 (30 days - stable domain, reference implementation well-tested)
