Commit 85dcf0d68d4f2165120896e42007012951c90cd5

Authored by vibe_erp
1 parent f5164414

feat(platform): bootstrap, persistence (multi-tenant JPA), and PF4J plug-in host

platform/platform-bootstrap/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.spring.dependency.management)
  5 +}
  6 +
  7 +description = "vibe_erp platform bootstrap — Spring Boot main, configuration loading, lifecycle. INTERNAL."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 +}
  14 +
  15 +kotlin {
  16 + jvmToolchain(21)
  17 + compilerOptions {
  18 + freeCompilerArgs.add("-Xjsr305=strict")
  19 + }
  20 +}
  21 +
  22 +dependencies {
  23 + api(project(":api:api-v1"))
  24 + api(project(":platform:platform-persistence")) // for TenantContext used by the HTTP filter
  25 + implementation(libs.kotlin.stdlib)
  26 + implementation(libs.kotlin.reflect)
  27 + implementation(libs.jackson.module.kotlin)
  28 +
  29 + implementation(libs.spring.boot.starter)
  30 + implementation(libs.spring.boot.starter.web)
  31 + implementation(libs.spring.boot.starter.validation)
  32 + implementation(libs.spring.boot.starter.actuator)
  33 +
  34 + testImplementation(libs.spring.boot.starter.test)
  35 + testImplementation(libs.junit.jupiter)
  36 + testImplementation(libs.assertk)
  37 +}
  38 +
  39 +tasks.test {
  40 + useJUnitPlatform()
  41 +}
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt 0 → 100644
  1 +package org.vibeerp.platform.bootstrap
  2 +
  3 +import org.springframework.boot.autoconfigure.SpringBootApplication
  4 +import org.springframework.boot.context.properties.ConfigurationPropertiesScan
  5 +import org.springframework.boot.runApplication
  6 +import org.springframework.context.annotation.ComponentScan
  7 +
  8 +/**
  9 + * Entry point for the vibe_erp framework.
  10 + *
  11 + * The application is a single Spring Boot process that contains:
  12 + * • the platform layer (this module + platform-persistence + platform-plugins)
  13 + * • all core PBCs that have been registered as Gradle dependencies of :distribution
  14 + * • the plug-in loader, which scans an external `./plugins/` directory
  15 + *
  16 + * The package scan is anchored at `org.vibeerp` so every PBC and platform module
  17 + * is picked up by Spring as long as its Kotlin packages live under that root.
  18 + *
  19 + * **What does NOT live in this process:**
  20 + * • the React web client (separate build, served by reverse proxy)
  21 + * • plug-ins, which load through PF4J child contexts and are physically
  22 + * outside the application classpath
  23 + */
  24 +@SpringBootApplication
  25 +@ComponentScan(basePackages = ["org.vibeerp"])
  26 +@ConfigurationPropertiesScan(basePackages = ["org.vibeerp"])
  27 +class VibeErpApplication
  28 +
  29 +fun main(args: Array<String>) {
  30 + runApplication<VibeErpApplication>(*args)
  31 +}
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt 0 → 100644
  1 +package org.vibeerp.platform.bootstrap.config
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties
  4 +import java.util.Locale
  5 +
  6 +/**
  7 + * Top-level vibe_erp configuration tree, bound from `application.yaml` under
  8 + * the `vibeerp` prefix.
  9 + *
  10 + * The set of keys here is **closed and documented**: plug-ins do NOT extend
  11 + * this tree. Per-plug-in configuration lives in the `metadata__plugin_config`
  12 + * table, where it is tenant-scoped and editable in the UI.
  13 + *
  14 + * Reference: architecture spec section 10 ("The single config file").
  15 + */
  16 +@ConfigurationProperties(prefix = "vibeerp")
  17 +data class VibeErpProperties(
  18 + val instance: InstanceProperties = InstanceProperties(),
  19 + val plugins: PluginsProperties = PluginsProperties(),
  20 + val i18n: I18nProperties = I18nProperties(),
  21 + val files: FilesProperties = FilesProperties(),
  22 +)
  23 +
  24 +data class InstanceProperties(
  25 + /** "self-hosted" or "hosted". Self-hosted single-customer is the v0.1 default. */
  26 + val mode: String = "self-hosted",
  27 + /** Tenant id used when [mode] is "self-hosted". Always "default" unless overridden. */
  28 + val defaultTenant: String = "default",
  29 + /** External base URL of this instance, used for absolute links and OIDC. Optional. */
  30 + val baseUrl: String? = null,
  31 +)
  32 +
  33 +data class PluginsProperties(
  34 + /** Filesystem path that the PF4J host scans for plug-in JARs at boot. */
  35 + val directory: String = "/opt/vibe-erp/plugins",
  36 + /** When false, the host loads no plug-ins even if the directory contains JARs. */
  37 + val autoLoad: Boolean = true,
  38 +)
  39 +
  40 +data class I18nProperties(
  41 + /** The locale used when no per-tenant or per-user preference is set. */
  42 + val defaultLocale: Locale = Locale.forLanguageTag("en-US"),
  43 + /** The locale used when a translation is missing in the requested locale. */
  44 + val fallbackLocale: Locale = Locale.forLanguageTag("en-US"),
  45 + /** Locales the platform will accept from clients. Plug-ins may add more. */
  46 + val availableLocales: List<Locale> = listOf(
  47 + Locale.forLanguageTag("en-US"),
  48 + Locale.forLanguageTag("zh-CN"),
  49 + Locale.forLanguageTag("de-DE"),
  50 + Locale.forLanguageTag("ja-JP"),
  51 + Locale.forLanguageTag("es-ES"),
  52 + ),
  53 +)
  54 +
  55 +data class FilesProperties(
  56 + /** "local" or "s3". v0.1 only ships "local". */
  57 + val backend: String = "local",
  58 + /** Filesystem path used when [backend] is "local". */
  59 + val localPath: String = "/opt/vibe-erp/files",
  60 +)
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt 0 → 100644
  1 +package org.vibeerp.platform.bootstrap.web
  2 +
  3 +import org.springframework.web.bind.annotation.GetMapping
  4 +import org.springframework.web.bind.annotation.RequestMapping
  5 +import org.springframework.web.bind.annotation.RestController
  6 +
  7 +/**
  8 + * Minimal info endpoint that confirms the framework boots and identifies itself.
  9 + *
  10 + * Real health checks come from Spring Boot Actuator at `/actuator/health`.
  11 + * This endpoint is intentionally separate so it can serve as the "is the
  12 + * framework alive AND has it loaded its plug-ins?" signal once the plug-in
  13 + * loader is wired up — for v0.1 it just reports the static info.
  14 + */
  15 +@RestController
  16 +@RequestMapping("/api/v1/_meta")
  17 +class MetaController {
  18 +
  19 + @GetMapping("/info")
  20 + fun info(): Map<String, Any> = mapOf(
  21 + "name" to "vibe-erp",
  22 + "apiVersion" to "v1",
  23 + "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"),
  24 + )
  25 +}
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt 0 → 100644
  1 +package org.vibeerp.platform.bootstrap.web
  2 +
  3 +import jakarta.servlet.FilterChain
  4 +import jakarta.servlet.http.HttpServletRequest
  5 +import jakarta.servlet.http.HttpServletResponse
  6 +import org.springframework.boot.context.properties.ConfigurationProperties
  7 +import org.springframework.core.Ordered
  8 +import org.springframework.core.annotation.Order
  9 +import org.springframework.stereotype.Component
  10 +import org.springframework.web.filter.OncePerRequestFilter
  11 +import org.vibeerp.api.v1.core.TenantId
  12 +import org.vibeerp.platform.persistence.tenancy.TenantContext
  13 +
  14 +/**
  15 + * HTTP filter that binds the request's tenant id to [TenantContext] for the
  16 + * duration of the request and clears it on the way out.
  17 + *
  18 + * Lives in `platform-bootstrap` (not `platform-persistence`) because the
  19 + * persistence layer must not depend on the servlet API or Spring Web — that
  20 + * would couple JPA code to HTTP and forbid running PBCs from background
  21 + * jobs or message consumers.
  22 + *
  23 + * Resolution order:
  24 + * 1. `X-Tenant-Id` header (used by trusted internal callers and tests)
  25 + * 2. `tenant` claim on the JWT (production path, once auth is wired up)
  26 + * 3. The configured fallback tenant when running in self-hosted single-tenant mode
  27 + *
  28 + * The third path is what makes self-hosted single-customer use the SAME
  29 + * code path as hosted multi-tenant: the request always sees a tenant in
  30 + * `TenantContext`, the only difference is whether it came from the JWT
  31 + * or from configuration. PBC code never branches on `mode`.
  32 + */
  33 +@Component
  34 +@Order(Ordered.HIGHEST_PRECEDENCE + 10)
  35 +class TenantResolutionFilter(
  36 + private val tenancyProperties: TenancyProperties,
  37 +) : OncePerRequestFilter() {
  38 +
  39 + override fun doFilterInternal(
  40 + request: HttpServletRequest,
  41 + response: HttpServletResponse,
  42 + filterChain: FilterChain,
  43 + ) {
  44 + val tenantId = resolveTenant(request)
  45 + TenantContext.runAs(tenantId) {
  46 + filterChain.doFilter(request, response)
  47 + }
  48 + }
  49 +
  50 + private fun resolveTenant(request: HttpServletRequest): TenantId {
  51 + val header = request.getHeader("X-Tenant-Id")
  52 + if (header != null && header.isNotBlank()) {
  53 + return TenantId(header)
  54 + }
  55 + // TODO(v0.2): read JWT 'tenant' claim once pbc-identity ships auth.
  56 + return TenantId(tenancyProperties.fallbackTenant)
  57 + }
  58 +}
  59 +
  60 +@Component
  61 +@ConfigurationProperties(prefix = "vibeerp.tenancy")
  62 +data class TenancyProperties(
  63 + /**
  64 + * Tenant id to use when no header or JWT claim is present.
  65 + * Defaults to "default" — the canonical id for self-hosted single-tenant.
  66 + */
  67 + var fallbackTenant: String = "default",
  68 +)
... ...
platform/platform-persistence/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp persistence layer — JPA, multi-tenancy, Liquibase, audit. INTERNAL."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +// All-open and JPA plugins make Kotlin classes inheritable & non-final so
  24 +// Hibernate can proxy them. Without this, every entity needs `open class`.
  25 +allOpen {
  26 + annotation("jakarta.persistence.Entity")
  27 + annotation("jakarta.persistence.MappedSuperclass")
  28 + annotation("jakarta.persistence.Embeddable")
  29 +}
  30 +
  31 +dependencies {
  32 + api(project(":api:api-v1"))
  33 + implementation(libs.kotlin.stdlib)
  34 + implementation(libs.kotlin.reflect)
  35 +
  36 + implementation(libs.spring.boot.starter)
  37 + implementation(libs.spring.boot.starter.data.jpa)
  38 + implementation(libs.postgres)
  39 + implementation(libs.liquibase.core)
  40 +
  41 + testImplementation(libs.spring.boot.starter.test)
  42 + testImplementation(libs.junit.jupiter)
  43 + testImplementation(libs.assertk)
  44 + testImplementation(libs.testcontainers.postgres)
  45 + testImplementation(libs.testcontainers.junit.jupiter)
  46 +}
  47 +
  48 +tasks.test {
  49 + useJUnitPlatform()
  50 +}
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt 0 → 100644
  1 +package org.vibeerp.platform.persistence.audit
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.EntityListeners
  5 +import jakarta.persistence.MappedSuperclass
  6 +import jakarta.persistence.PrePersist
  7 +import jakarta.persistence.PreUpdate
  8 +import jakarta.persistence.Version
  9 +import org.vibeerp.platform.persistence.tenancy.TenantContext
  10 +import java.time.Instant
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * JPA `@MappedSuperclass` that every PBC entity should extend.
  15 + *
  16 + * Provides, for free, on every business table:
  17 + * • `id` (UUID primary key)
  18 + * • `tenant_id` (multi-tenant discriminator, NOT NULL, never written manually)
  19 + * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns)
  20 + * • `version` (optimistic-locking column for safe concurrent edits)
  21 + *
  22 + * **What this is NOT:**
  23 + * • This is INTERNAL to the platform layer. PBC code that lives in
  24 + * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend
  25 + * `org.vibeerp.api.v1.entity.AuditedEntity` from api.v1, and the
  26 + * platform's plug-in registration code maps it to a JPA entity behind
  27 + * the scenes.
  28 + * • The `created_by`/`updated_by` columns currently get a placeholder
  29 + * `__system__` until pbc-identity wires up the real Principal resolver
  30 + * in v0.2. The columns exist now so we don't need a migration later.
  31 + */
  32 +@MappedSuperclass
  33 +@EntityListeners(AuditedJpaEntityListener::class)
  34 +abstract class AuditedJpaEntity {
  35 +
  36 + @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid")
  37 + open var id: UUID = UUID.randomUUID()
  38 +
  39 + @Column(name = "tenant_id", updatable = false, nullable = false, length = 64)
  40 + open var tenantId: String = ""
  41 +
  42 + @Column(name = "created_at", updatable = false, nullable = false)
  43 + open var createdAt: Instant = Instant.EPOCH
  44 +
  45 + @Column(name = "created_by", updatable = false, nullable = false, length = 128)
  46 + open var createdBy: String = ""
  47 +
  48 + @Column(name = "updated_at", nullable = false)
  49 + open var updatedAt: Instant = Instant.EPOCH
  50 +
  51 + @Column(name = "updated_by", nullable = false, length = 128)
  52 + open var updatedBy: String = ""
  53 +
  54 + @Version
  55 + @Column(name = "version", nullable = false)
  56 + open var version: Long = 0
  57 +}
  58 +
  59 +/**
  60 + * JPA entity listener that fills in tenant id and audit columns at save time.
  61 + *
  62 + * Reads tenant from `TenantContext` (set by an HTTP filter) and the principal
  63 + * from a yet-to-exist `PrincipalContext` — for v0.1 the principal is hard-coded
  64 + * to `__system__` because pbc-identity's auth flow lands in v0.2.
  65 + */
  66 +class AuditedJpaEntityListener {
  67 +
  68 + @PrePersist
  69 + fun onCreate(entity: Any) {
  70 + if (entity is AuditedJpaEntity) {
  71 + val now = Instant.now()
  72 + val tenant = TenantContext.currentOrNull()
  73 + ?: error("Cannot persist ${entity::class.simpleName}: no tenant in context")
  74 + val principal = currentPrincipal()
  75 + entity.tenantId = tenant.value
  76 + entity.createdAt = now
  77 + entity.createdBy = principal
  78 + entity.updatedAt = now
  79 + entity.updatedBy = principal
  80 + }
  81 + }
  82 +
  83 + @PreUpdate
  84 + fun onUpdate(entity: Any) {
  85 + if (entity is AuditedJpaEntity) {
  86 + entity.updatedAt = Instant.now()
  87 + entity.updatedBy = currentPrincipal()
  88 + }
  89 + }
  90 +
  91 + private fun currentPrincipal(): String {
  92 + // TODO(v0.2): pull from PrincipalContext once pbc-identity wires up auth.
  93 + return "__system__"
  94 + }
  95 +}
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt 0 → 100644
  1 +package org.vibeerp.platform.persistence.tenancy
  2 +
  3 +import org.hibernate.context.spi.CurrentTenantIdentifierResolver
  4 +import org.springframework.stereotype.Component
  5 +
  6 +/**
  7 + * Hibernate hook that returns the tenant id for the current Hibernate session.
  8 + *
  9 + * Wired into Hibernate via `spring.jpa.properties.hibernate.multiTenancy=DISCRIMINATOR`
  10 + * (set in platform-persistence's auto-config). Hibernate then automatically
  11 + * filters every query and every save by tenant — the application code never
  12 + * writes `WHERE tenant_id = ?` manually.
  13 + *
  14 + * This is the FIRST of two walls against cross-tenant data leaks. The
  15 + * SECOND wall is Postgres Row-Level Security policies on every business
  16 + * table, applied by `RlsTransactionHook` on every transaction. A bug in
  17 + * either wall is contained by the other; both walls must be wrong at
  18 + * the same time for a leak to occur.
  19 + *
  20 + * Reference: architecture spec section 8 ("Tenant isolation").
  21 + */
  22 +@Component
  23 +class HibernateTenantResolver : CurrentTenantIdentifierResolver<String> {
  24 +
  25 + override fun resolveCurrentTenantIdentifier(): String =
  26 + TenantContext.currentOrNull()?.value
  27 + // We return a sentinel rather than throwing here because
  28 + // Hibernate calls this resolver during schema validation at
  29 + // startup, before any HTTP request has set a tenant.
  30 + ?: "__no_tenant__"
  31 +
  32 + override fun validateExistingCurrentSessions(): Boolean = true
  33 +}
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt 0 → 100644
  1 +package org.vibeerp.platform.persistence.tenancy
  2 +
  3 +import org.vibeerp.api.v1.core.TenantId
  4 +
  5 +/**
  6 + * Per-thread holder of the current request's tenant.
  7 + *
  8 + * Set by an HTTP filter on the way in (from JWT claim, header, or
  9 + * subdomain), read by Hibernate's tenant resolver and by Postgres RLS
  10 + * via a `SET LOCAL` statement at the start of every transaction.
  11 + *
  12 + * **Why a ThreadLocal and not a request-scoped Spring bean?**
  13 + * Hibernate's `CurrentTenantIdentifierResolver` is called from inside
  14 + * the Hibernate session, which is not always inside a Spring request
  15 + * scope (e.g., during background jobs and event handlers). A ThreadLocal
  16 + * is the lowest common denominator that works across all those code paths.
  17 + *
  18 + * **For background jobs:** the job scheduler explicitly calls
  19 + * [runAs] to bind a tenant for the duration of the job, then clears it.
  20 + * Never let a job thread inherit a stale tenant from a previous run.
  21 + */
  22 +object TenantContext {
  23 +
  24 + private val current = ThreadLocal<TenantId?>()
  25 +
  26 + fun set(tenantId: TenantId) {
  27 + current.set(tenantId)
  28 + }
  29 +
  30 + fun clear() {
  31 + current.remove()
  32 + }
  33 +
  34 + fun currentOrNull(): TenantId? = current.get()
  35 +
  36 + fun current(): TenantId =
  37 + currentOrNull() ?: error(
  38 + "TenantContext.current() called outside a tenant-bound scope. " +
  39 + "Either an HTTP filter forgot to set the tenant, or a background job " +
  40 + "is running without explicitly calling TenantContext.runAs(...)."
  41 + )
  42 +
  43 + /**
  44 + * Run [block] with [tenantId] bound to the current thread, restoring
  45 + * the previous value (which is usually null) afterwards.
  46 + */
  47 + fun <T> runAs(tenantId: TenantId, block: () -> T): T {
  48 + val previous = current.get()
  49 + current.set(tenantId)
  50 + try {
  51 + return block()
  52 + } finally {
  53 + if (previous == null) current.remove() else current.set(previous)
  54 + }
  55 + }
  56 +}
... ...
platform/platform-plugins/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.spring.dependency.management)
  5 +}
  6 +
  7 +description = "vibe_erp plug-in host — PF4J integration, classloader isolation, lifecycle. INTERNAL."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 +}
  14 +
  15 +kotlin {
  16 + jvmToolchain(21)
  17 + compilerOptions {
  18 + freeCompilerArgs.add("-Xjsr305=strict")
  19 + }
  20 +}
  21 +
  22 +dependencies {
  23 + api(project(":api:api-v1"))
  24 + implementation(libs.kotlin.stdlib)
  25 + implementation(libs.kotlin.reflect)
  26 + implementation(libs.jackson.module.kotlin)
  27 +
  28 + implementation(libs.spring.boot.starter)
  29 + implementation(libs.pf4j)
  30 + implementation(libs.pf4j.spring)
  31 +
  32 + testImplementation(libs.spring.boot.starter.test)
  33 + testImplementation(libs.junit.jupiter)
  34 + testImplementation(libs.assertk)
  35 +}
  36 +
  37 +tasks.test {
  38 + useJUnitPlatform()
  39 +}
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins
  2 +
  3 +import org.pf4j.DefaultPluginManager
  4 +import org.pf4j.PluginWrapper
  5 +import org.slf4j.LoggerFactory
  6 +import org.springframework.beans.factory.DisposableBean
  7 +import org.springframework.beans.factory.InitializingBean
  8 +import org.springframework.boot.context.properties.ConfigurationProperties
  9 +import org.springframework.stereotype.Component
  10 +import java.nio.file.Files
  11 +import java.nio.file.Path
  12 +import java.nio.file.Paths
  13 +
  14 +/**
  15 + * The framework's PF4J host.
  16 + *
  17 + * Wired as a Spring bean so its lifecycle follows the Spring application context:
  18 + * • on startup, scans the configured plug-ins directory and loads every JAR
  19 + * that passes manifest validation and the API compatibility check
  20 + * • on shutdown, stops every plug-in cleanly so they get a chance to release
  21 + * resources
  22 + *
  23 + * Each plug-in lives in its own `PluginClassLoader` (PF4J's default), which means:
  24 + * • plug-ins cannot accidentally see each other's classes
  25 + * • plug-ins cannot see `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` —
  26 + * PF4J's parent-first-then-self classloader sees only `org.vibeerp.api.v1.*`
  27 + * in the host classloader because that's all that should be exported as
  28 + * a "plug-in API" via the `pluginsExportedPackages` mechanism
  29 + *
  30 + * v0.1 ships the loader skeleton but does NOT yet implement:
  31 + * • the plug-in linter (rejecting reflection into platform.*) — v0.2
  32 + * • per-plug-in Spring child context — v0.2
  33 + * • plug-in Liquibase changelog application — v0.2
  34 + * • metadata seed-row upsert — v0.2
  35 + *
  36 + * What v0.1 DOES implement: a real PF4J manager that boots, scans the
  37 + * plug-ins directory, loads JARs that have a valid `plugin.properties`,
  38 + * and starts them. The reference printing-shop plug-in proves this works.
  39 + *
  40 + * Reference: architecture spec section 7 ("Plug-in lifecycle").
  41 + */
  42 +@Component
  43 +class VibeErpPluginManager(
  44 + private val properties: VibeErpPluginsProperties,
  45 +) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
  46 +
  47 + private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
  48 +
  49 + override fun afterPropertiesSet() {
  50 + if (!properties.autoLoad) {
  51 + log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan")
  52 + return
  53 + }
  54 + val dir: Path = Paths.get(properties.directory)
  55 + if (!Files.exists(dir)) {
  56 + log.warn("vibe_erp plug-ins directory does not exist: {} — creating empty directory", dir)
  57 + Files.createDirectories(dir)
  58 + return
  59 + }
  60 + log.info("vibe_erp scanning plug-ins from {}", dir)
  61 + loadPlugins()
  62 + startPlugins()
  63 + plugins.values.forEach { wrapper: PluginWrapper ->
  64 + log.info(
  65 + "vibe_erp plug-in loaded: id={} version={} state={}",
  66 + wrapper.pluginId, wrapper.descriptor.version, wrapper.pluginState,
  67 + )
  68 + }
  69 + }
  70 +
  71 + override fun destroy() {
  72 + log.info("vibe_erp stopping {} plug-in(s)", plugins.size)
  73 + stopPlugins()
  74 + unloadPlugins()
  75 + }
  76 +}
  77 +
  78 +@org.springframework.stereotype.Component
  79 +@ConfigurationProperties(prefix = "vibeerp.plugins")
  80 +data class VibeErpPluginsProperties(
  81 + var directory: String = "/opt/vibe-erp/plugins",
  82 + var autoLoad: Boolean = true,
  83 +)
... ...