Files
Jakub Zych 85dc7c7464 docs(10): research core plugins domain
Phase 10: Core Plugins
- User plugin patterns from Golem15.User reference
- Blog plugin patterns from Golem15.Blog reference
- TinyMCE for WYSIWYG editing
- Frontend authentication with cookie/JWT hybrid
- Nested set model for categories
- Component patterns for posts/account/session
2026-02-05 16:00:33 +01:00

46 KiB

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

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

{
  "dependencies": {
    "tinymce": "^7.0.0"
  }
}

Architecture Patterns

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:

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

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

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

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

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

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

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

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

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

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

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

{# 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 = 2shared_categories + 1shared_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)

Secondary (MEDIUM confidence)

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)