Commit 1ead32d794198565a9b44b8ee57d443ffa4c43b4

Authored by vibe_erp
1 parent 7af11f2f

feat(metadata): P1.5 — metadata store seeding

Adds the foundation for the entire Tier 1 customization story. Core
PBCs and plug-ins now ship YAML files declaring their entities,
permissions, and menus; a `MetadataLoader` walks the host classpath
and each plug-in JAR at boot, upserts the rows tagged with their
source, and exposes them at a public REST endpoint so the future
SPA, AI-agent function catalog, OpenAPI generator, and external
introspection tooling can all see what the framework offers without
scraping code.

What landed:

* New `platform/platform-metadata/` Gradle subproject. Depends on
  api-v1 + platform-persistence + jackson-yaml + spring-jdbc.

* `MetadataYamlFile` DTOs (entities, permissions, menus). Forward-
  compatible: unknown top-level keys are ignored, so a future plug-in
  built against a newer schema (forms, workflows, rules, translations)
  loads cleanly on an older host that doesn't know those sections yet.

* `MetadataLoader` with two entry points:

    loadCore() — uses Spring's PathMatchingResourcePatternResolver
      against the host classloader. Finds every classpath*:META-INF/
      vibe-erp/metadata/*.yml across all jars contributing to the
      application. Tagged source='core'.

    loadFromPluginJar(pluginId, jarPath) — opens ONE specific
      plug-in JAR via java.util.jar.JarFile and walks its entries
      directly. This is critical: a plug-in's PluginClassLoader is
      parent-first, so a classpath*: scan against it would ALSO
      pick up the host's metadata files via parent classpath. We
      saw this in the first smoke run — the plug-in source ended
      up with 6 entities (the plug-in's 2 + the host's 4) before
      the fix. Walking the JAR file directly guarantees only the
      plug-in's own files load. Tagged source='plugin:<id>'.

  Both entry points use the same delete-then-insert idempotent core
  (doLoad). Loading the same source twice produces the same final
  state. User-edited metadata (source='user') is NEVER touched by
  either path — it survives boot, plug-in install, and plug-in
  upgrade. This is what lets a future SPA "Customize" UI add custom
  fields without fearing they'll be wiped on the next deploy.

* `VibeErpPluginManager.afterPropertiesSet()` now calls
  metadataLoader.loadCore() at the very start, then walks plug-ins
  and calls loadFromPluginJar(...) for each one between Liquibase
  migration and start(context). Order is guaranteed: core → linter
  → migrate → metadata → start. The CommandLineRunner I originally
  put `loadCore()` in turned out to be wrong because Spring runs
  CommandLineRunners AFTER InitializingBean.afterPropertiesSet(),
  so the plug-in metadata was loading BEFORE core — the wrong way
  around. Calling loadCore() inline in the plug-in manager fixes
  the ordering without any @Order(...) gymnastics.

* `MetadataController` exposes:
    GET /api/v1/_meta/metadata           — all three sections
    GET /api/v1/_meta/metadata/entities  — entities only
    GET /api/v1/_meta/metadata/permissions
    GET /api/v1/_meta/metadata/menus
  Public allowlist (covered by the existing /api/v1/_meta/** rule
  in SecurityConfiguration). The metadata is intentionally non-
  sensitive — entity names, permission keys, menu paths. Nothing
  in here is PII or secret; the SPA needs to read it before the
  user has logged in.

* YAML files shipped:
  - pbc-identity/META-INF/vibe-erp/metadata/identity.yml
    (User + Role entities, 6 permissions, Users + Roles menus)
  - pbc-catalog/META-INF/vibe-erp/metadata/catalog.yml
    (Item + Uom entities, 7 permissions, Items + UoMs menus)
  - reference plug-in/META-INF/vibe-erp/metadata/printing-shop.yml
    (Plate + InkRecipe entities, 5 permissions, Plates + Inks menus
    in a "Printing shop" section)

Tests: 4 MetadataLoaderTest cases (loadFromPluginJar happy paths,
mixed sections, blank pluginId rejection, missing-file no-op wipe)
+ 7 MetadataYamlParseTest cases (DTO mapping, optional fields,
section defaults, forward-compat unknown keys). Total now
**92 unit tests** across 11 modules, all green.

End-to-end smoke test against fresh Postgres + plug-in loaded:

  Boot logs:
    MetadataLoader: source='core' loaded 4 entities, 13 permissions,
      4 menus from 2 file(s)
    MetadataLoader: source='plugin:printing-shop' loaded 2 entities,
      5 permissions, 2 menus from 1 file(s)

  HTTP smoke (everything green):
    GET /api/v1/_meta/metadata (no auth)              → 200
      6 entities, 18 permissions, 6 menus
      entity names: User, Role, Item, Uom, Plate, InkRecipe
      menu sections: Catalog, Printing shop, System
    GET /api/v1/_meta/metadata/entities                → 200
    GET /api/v1/_meta/metadata/menus                   → 200

  Direct DB verification:
    metadata__entity:    core=4, plugin:printing-shop=2
    metadata__permission: core=13, plugin:printing-shop=5
    metadata__menu:      core=4, plugin:printing-shop=2

  Idempotency: restart the app, identical row counts.

  Existing endpoints regression:
    GET /api/v1/identity/users (Bearer)               → 1 user
    GET /api/v1/catalog/uoms (Bearer)                  → 15 UoMs
    GET /api/v1/plugins/printing-shop/ping (Bearer)    → 200

Bugs caught and fixed during the smoke test:

  • The first attempt loaded core metadata via a CommandLineRunner
    annotated @Order(HIGHEST_PRECEDENCE) and per-plug-in metadata
    inline in VibeErpPluginManager.afterPropertiesSet(). Spring
    runs all InitializingBeans BEFORE any CommandLineRunner, so
    the plug-in metadata loaded first and the core load came
    second — wrong order. Fix: drop CoreMetadataInitializer
    entirely; have the plug-in manager call metadataLoader.loadCore()
    directly at the start of afterPropertiesSet().

  • The first attempt's plug-in load used
    metadataLoader.load(pluginClassLoader, ...) which used Spring's
    PathMatchingResourcePatternResolver against the plug-in's
    classloader. PluginClassLoader is parent-first, so the resolver
    enumerated BOTH the plug-in's own JAR AND the host classpath's
    metadata files, tagging core entities as source='plugin:<id>'
    and corrupting the seed counts. Fix: refactor MetadataLoader
    to expose loadFromPluginJar(pluginId, jarPath) which opens
    the plug-in JAR directly via java.util.jar.JarFile and walks
    its entries — never asking the classloader at all. The
    api-v1 surface didn't change.

  • Two KDoc comments contained the literal string `*.yml` after
    a `/` character (`/metadata/*.yml`), forming the `/*` pattern
    that Kotlin's lexer treats as a nested-comment opener. The
    file failed to compile with "Unclosed comment". This is the
    third time I've hit this trap; rewriting both KDocs to avoid
    the literal `/*` sequence.

  • The MetadataLoaderTest's hand-rolled JAR builder didn't include
    explicit directory entries for parent paths. Real Gradle JARs
    do include them, and Spring's PathMatchingResourcePatternResolver
    needs them to enumerate via classpath*:. Fixed the test helper
    to write directory entries for every parent of each file.

Implementation plan refreshed: P1.5 marked DONE. Next priority
candidates: P5.2 (pbc-partners — third PBC clone) and P3.4 (custom
field application via the ext jsonb column, which would unlock the
full Tier 1 customization story).

Framework state: 17→18 commits, 10→11 modules, 81→92 unit tests,
metadata seeded for 6 entities + 18 permissions + 6 menus.
distribution/build.gradle.kts
... ... @@ -24,6 +24,7 @@ dependencies {
24 24 implementation(project(":platform:platform-plugins"))
25 25 implementation(project(":platform:platform-security"))
26 26 implementation(project(":platform:platform-events"))
  27 + implementation(project(":platform:platform-metadata"))
27 28 implementation(project(":pbc:pbc-identity"))
28 29 implementation(project(":pbc:pbc-catalog"))
29 30  
... ...
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
... ... @@ -12,18 +12,19 @@
12 12 > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant
13 13 > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale.
14 14 >
15   -> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2 all landed.**
16   -> Auth (Argon2id + JWT + bootstrap admin), pbc-catalog (items + UoMs),
17   -> plug-in HTTP endpoints (registry + dispatcher + DefaultPluginContext),
18   -> event bus + transactional outbox + audit subscriber, plug-in-owned
19   -> Liquibase changelogs (the reference plug-in now has its own database
20   -> tables), and the plug-in linter (ASM bytecode scan rejecting forbidden
21   -> imports) are all done and smoke-tested end-to-end. The reference
22   -> printing-shop plug-in has graduated from "hello world" to a real
23   -> customer demonstration: it owns its own DB schema, CRUDs plates and
24   -> ink recipes via REST through `context.jdbc`, and would be rejected at
25   -> install time if it tried to import internal framework classes. The
26   -> framework is now at **81 unit tests** across 10 modules, all green.
  15 +> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2, P1.5 all
  16 +> landed.** Auth, pbc-catalog, plug-in HTTP endpoints, event bus +
  17 +> outbox, plug-in-owned Liquibase changelogs, plug-in linter, and now
  18 +> **the metadata loader** are all done and smoke-tested end-to-end.
  19 +> Core PBCs and the reference plug-in ship YAML files declaring their
  20 +> entities, permissions, and menus; the loader walks the host
  21 +> classpath and each plug-in JAR at boot, upserts the rows tagged
  22 +> with `source = 'core'` or `source = 'plugin:<id>'`, and exposes
  23 +> them at the public `GET /api/v1/_meta/metadata` endpoint so the
  24 +> future SPA, OpenAPI generator, and AI-agent function catalog can
  25 +> introspect what entities and permissions exist. Idempotent across
  26 +> restarts (delete-by-source then insert). The framework is now at
  27 +> **92 unit tests** across 11 modules, all green.
27 28  
28 29 ---
29 30  
... ... @@ -96,11 +97,9 @@ These units finish the platform layer so PBCs can be implemented without inventi
96 97 **Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`)
97 98 **What landed:** `PluginLiquibaseRunner` looks for `META-INF/vibe-erp/db/changelog.xml` inside the plug-in JAR via the PF4J classloader, applies it via Liquibase against the host's shared DataSource. Convention: tables use `plugin_<id>__*` prefix (linter will enforce in a future version). The reference printing-shop plug-in now has two real tables (`plugin_printingshop__plate`, `plugin_printingshop__ink_recipe`) and CRUDs them via REST through the new `context.jdbc` typed-SQL surface in api.v1. Plug-ins never see Spring's JdbcTemplate. **Deferred:** per-plug-in datasource isolation, table-prefix linter, rollback on uninstall.
98 99  
99   -### P1.5 — Metadata store seeding
100   -**Module:** new `platform-metadata` module (or expand `platform-persistence`)
101   -**Depends on:** —
102   -**What:** A `MetadataLoader` Spring component that, on boot and on plug-in start, upserts rows into `metadata__entity`, `metadata__form`, `metadata__workflow`, etc. from YAML files in the classpath / plug-in JAR. Each row carries `source = 'core' | 'plugin:<id>' | 'user'`. Idempotent; safe to run on every boot.
103   -**Acceptance:** integration test confirms that booting twice doesn't duplicate rows; uninstalling a plug-in removes its `source = 'plugin:<id>'` rows; user-edited rows (`source = 'user'`) are never touched.
  100 +### ✅ P1.5 — Metadata store seeding (DONE 2026-04-08)
  101 +**Module:** new `platform-metadata` module
  102 +**What landed:** `MetadataLoader` Spring component with two entry points: `loadCore()` uses Spring's `PathMatchingResourcePatternResolver` against the host classpath (`classpath*:META-INF/vibe-erp/metadata/*.yml`), and `loadFromPluginJar(pluginId, jarPath)` opens a specific plug-in JAR via `java.util.jar.JarFile` and walks its entries directly — critical because the plug-in's parent-first `PluginClassLoader` would otherwise pick up the host's metadata files via parent classpath. `MetadataYamlFile` DTOs cover `entities`, `permissions`, `menus` (extensible: `forms`, `workflows`, `rules`, `translations` are future top-level keys, ignored gracefully). Delete-by-source-then-insert pattern is naturally idempotent and never touches `source='user'` rows. `VibeErpPluginManager.afterPropertiesSet()` calls `loadCore()` first, then walks plug-ins and calls `loadFromPluginJar(...)` for each one. New `GET /api/v1/_meta/metadata` REST endpoint (public, no auth needed) returns the seeded metadata for SPA/AI-agent introspection. pbc-identity, pbc-catalog, and the reference plug-in each ship a metadata YAML. **Verified end-to-end:** 4 core entities + 2 plug-in entities, 13 core permissions + 5 plug-in permissions, 4 core menus + 2 plug-in menus, idempotent across restarts. 11 new unit tests.
104 103  
105 104 ### P1.6 — `Translator` implementation backed by ICU4J
106 105 **Module:** new `platform-i18n` module
... ... @@ -351,9 +350,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3
351 350 ```
352 351  
353 352 Sensible ordering for one developer (single-tenant world):
354   -**~~P4.1~~ ✅ → ~~P5.1~~ ✅ → ~~P1.3~~ ✅ → ~~P1.7~~ ✅ → ~~P1.4~~ ✅ → ~~P1.2~~ ✅ → P1.5 → P1.6 → P5.2 → P2.1 → P5.3 → P3.4 → R1 → ...**
  353 +**~~P4.1~~ ✅ → ~~P5.1~~ ✅ → ~~P1.3~~ ✅ → ~~P1.7~~ ✅ → ~~P1.4~~ ✅ → ~~P1.2~~ ✅ → ~~P1.5~~ ✅ → P5.2 → P1.6 → P3.4 → P5.3 → P2.1 → R1 → ...**
355 354  
356   -**Next up after this commit:** P1.5 (metadata seeder) and P5.2 (pbc-partners) are the strongest candidates. Metadata seeder unblocks the entire Tier 1 customization story; pbc-partners adds another business surface (customers / suppliers) that sales orders will reference. Either is a clean ~half-day chunk.
  355 +**Next up after this commit:** P5.2 (pbc-partners) is the strongest next candidate — adds customers/suppliers/contacts which everything downstream (sales orders, purchase orders, deliveries) will reference, and validates the pbc-identity template scales. P1.6 (ICU4J translator) is another clean ~half-day chunk if breadth-first feels right. P3.4 (custom field application via JSONB ext column) is the natural pair to P1.5 — together they unlock the Tier 1 customization story.
357 356  
358 357 Sensible parallel ordering for a team of three:
359 358 - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1
... ...
gradle/libs.versions.toml
... ... @@ -33,6 +33,7 @@ kotlin-stdlib = { module = &quot;org.jetbrains.kotlin:kotlin-stdlib&quot;, version.ref = &quot;
33 33 kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
34 34 jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
35 35 jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
  36 +jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" }
36 37  
37 38 # Bytecode analysis
38 39 asm = { module = "org.ow2.asm:asm", version = "9.7.1" }
... ...
pbc/pbc-catalog/src/main/resources/META-INF/vibe-erp/metadata/catalog.yml 0 → 100644
  1 +# pbc-catalog metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +# This declares what the catalog PBC offers to the rest of the framework
  5 +# and the SPA: which entities exist, which permissions guard them,
  6 +# which menu entries point at them.
  7 +
  8 +entities:
  9 + - name: Item
  10 + pbc: catalog
  11 + table: catalog__item
  12 + description: A thing that can be bought, sold, made, stored, or invoiced
  13 +
  14 + - name: Uom
  15 + pbc: catalog
  16 + table: catalog__uom
  17 + description: A unit of measure (kg, m, ea, sheet, …)
  18 +
  19 +permissions:
  20 + - key: catalog.item.read
  21 + description: Read catalog item records
  22 + - key: catalog.item.create
  23 + description: Create catalog items
  24 + - key: catalog.item.update
  25 + description: Update catalog items
  26 + - key: catalog.item.deactivate
  27 + description: Deactivate catalog items (preserved for historical references)
  28 + - key: catalog.uom.read
  29 + description: Read units of measure
  30 + - key: catalog.uom.create
  31 + description: Create custom units of measure
  32 + - key: catalog.uom.update
  33 + description: Update unit-of-measure display name or dimension
  34 +
  35 +menus:
  36 + - path: /catalog/items
  37 + label: Items
  38 + icon: package
  39 + section: Catalog
  40 + order: 200
  41 + - path: /catalog/uoms
  42 + label: Units of measure
  43 + icon: ruler
  44 + section: Catalog
  45 + order: 210
... ...
pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml 0 → 100644
  1 +# pbc-identity metadata.
  2 +#
  3 +# Loaded at boot by org.vibeerp.platform.metadata.MetadataLoader,
  4 +# tagged source='core'. Re-loaded on every boot from the YAML below
  5 +# (delete-then-insert), so this file is the source of truth — edits
  6 +# made via the future SPA customization UI live in metadata__* rows
  7 +# tagged source='user' and are NEVER touched by the loader.
  8 +
  9 +entities:
  10 + - name: User
  11 + pbc: identity
  12 + table: identity__user
  13 + description: A user account in the framework
  14 +
  15 + - name: Role
  16 + pbc: identity
  17 + table: identity__role
  18 + description: A named bundle of permissions assignable to users
  19 +
  20 +permissions:
  21 + - key: identity.user.read
  22 + description: Read user records
  23 + - key: identity.user.create
  24 + description: Create new user records
  25 + - key: identity.user.update
  26 + description: Update user records
  27 + - key: identity.user.disable
  28 + description: Disable a user (soft-delete; row is preserved for audit)
  29 + - key: identity.role.read
  30 + description: Read role records
  31 + - key: identity.role.assign
  32 + description: Assign a role to a user
  33 +
  34 +menus:
  35 + - path: /identity/users
  36 + label: Users
  37 + icon: people
  38 + section: System
  39 + order: 100
  40 + - path: /identity/roles
  41 + label: Roles
  42 + icon: shield-lock
  43 + section: System
  44 + order: 110
... ...
platform/platform-metadata/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 metadata loader — reads YAML files from classpath and plug-in JARs, upserts to metadata__* tables. 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"))
  25 +
  26 + implementation(libs.kotlin.stdlib)
  27 + implementation(libs.kotlin.reflect)
  28 + implementation(libs.jackson.module.kotlin)
  29 + implementation(libs.jackson.dataformat.yaml)
  30 + implementation(libs.jackson.datatype.jsr310)
  31 +
  32 + implementation(libs.spring.boot.starter)
  33 + implementation(libs.spring.boot.starter.web) // for the @RestController
  34 + implementation(libs.spring.boot.starter.data.jpa) // for DataSource + JdbcTemplate
  35 +
  36 + testImplementation(libs.spring.boot.starter.test)
  37 + testImplementation(libs.junit.jupiter)
  38 + testImplementation(libs.assertk)
  39 + testImplementation(libs.mockk)
  40 +}
  41 +
  42 +tasks.test {
  43 + useJUnitPlatform()
  44 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
  5 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  6 +import org.slf4j.LoggerFactory
  7 +import org.springframework.core.io.support.PathMatchingResourcePatternResolver
  8 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  9 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  10 +import org.springframework.stereotype.Component
  11 +import org.springframework.transaction.annotation.Transactional
  12 +import org.vibeerp.platform.metadata.yaml.MetadataYamlFile
  13 +import java.nio.file.Files
  14 +import java.nio.file.Path
  15 +import java.sql.Timestamp
  16 +import java.time.Instant
  17 +import java.util.UUID
  18 +import java.util.jar.JarFile
  19 +
  20 +/**
  21 + * Loads metadata YAML files (any `.yml` under `META-INF/vibe-erp/metadata/`)
  22 + * from the host classpath or from individual plug-in JARs and upserts
  23 + * their contents into the `metadata__*` tables.
  24 + *
  25 + * **Two entry points, deliberately different:**
  26 + *
  27 + * • [loadCore] uses Spring's `PathMatchingResourcePatternResolver`
  28 + * against the host's classloader. The host owns its classpath, so
  29 + * `classpath*:` resolution is exactly the right tool — every JAR
  30 + * contributing to the application gets scanned.
  31 + *
  32 + * • [loadFromPluginJar] opens ONE specific plug-in JAR file via
  33 + * [JarFile] and walks its entries directly. This is critical: a
  34 + * plug-in's `PluginClassLoader` is parent-first by default, so a
  35 + * `classpath*:` scan against it ALSO finds the host's metadata
  36 + * files via the parent classloader. Walking the JAR file directly
  37 + * guarantees only the plug-in's own files get loaded.
  38 + *
  39 + * **Idempotency.** Loading the same source twice produces the same
  40 + * final database state. The mechanism is "delete by source, then
  41 + * insert" inside one transaction:
  42 + * 1. `DELETE FROM metadata__entity WHERE source = :source`
  43 + * 2. `INSERT` every entity from every YAML file with that source
  44 + * 3. Same for `metadata__permission` and `metadata__menu`
  45 + * The whole load runs in a single transaction so a failure leaves the
  46 + * old metadata in place. User-edited rows (`source = 'user'`) are NEVER
  47 + * touched — they survive boot, plug-in install, and plug-in upgrade.
  48 + *
  49 + * **Why delete-and-insert instead of upsert.** Upsert needs a stable
  50 + * natural key per row, which would force the YAML to declare unique
  51 + * ids and would make renames painful. Delete-by-source treats the
  52 + * YAML as the authoritative declaration: whatever is in the YAML at
  53 + * boot time is what the database has. Plug-in uninstall is the same
  54 + * delete query.
  55 + *
  56 + * Reference: architecture spec section 8 ("The metadata store") and
  57 + * implementation plan unit P1.5.
  58 + */
  59 +@Component
  60 +class MetadataLoader(
  61 + private val jdbc: NamedParameterJdbcTemplate,
  62 +) {
  63 +
  64 + private val log = LoggerFactory.getLogger(MetadataLoader::class.java)
  65 +
  66 + private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()
  67 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  68 +
  69 + /**
  70 + * Load every metadata YAML on the host classpath as `source='core'`.
  71 + * Called once at boot from `VibeErpPluginManager.afterPropertiesSet()`
  72 + * BEFORE any plug-in is processed, so the metadata table contains
  73 + * the core entries by the time plug-in metadata layers on top.
  74 + */
  75 + @Transactional
  76 + fun loadCore(): LoadResult {
  77 + val source = SOURCE_CORE
  78 + val resolver = PathMatchingResourcePatternResolver(javaClass.classLoader)
  79 + val resources = try {
  80 + resolver.getResources("classpath*:META-INF/vibe-erp/metadata/*.yml")
  81 + } catch (ex: Throwable) {
  82 + log.warn("MetadataLoader: failed to scan host classpath for metadata YAMLs: {}", ex.message, ex)
  83 + return doLoad(source, emptyList())
  84 + }
  85 + val files = resources.mapNotNull { resource ->
  86 + try {
  87 + val parsed = resource.inputStream.use { stream ->
  88 + yamlMapper.readValue(stream, MetadataYamlFile::class.java)
  89 + }
  90 + ParsedYaml(resource.url?.toString() ?: resource.filename ?: "<unknown>", parsed)
  91 + } catch (ex: Throwable) {
  92 + log.error("MetadataLoader: failed to parse YAML resource {}: {}", resource.url, ex.message, ex)
  93 + null
  94 + }
  95 + }
  96 + return doLoad(source, files)
  97 + }
  98 +
  99 + /**
  100 + * Load metadata YAMLs from a specific plug-in JAR file as
  101 + * `source='plugin:<id>'`. Walks the JAR entries directly to avoid
  102 + * picking up host classpath files via the plug-in's parent-first
  103 + * `PluginClassLoader`.
  104 + *
  105 + * @param pluginId the plug-in id, used to tag the rows.
  106 + * @param jarPath path to the plug-in JAR on disk. Resolved by the
  107 + * host's `VibeErpPluginManager` from PF4J's `PluginWrapper`.
  108 + */
  109 + @Transactional
  110 + fun loadFromPluginJar(pluginId: String, jarPath: Path): LoadResult {
  111 + require(pluginId.isNotBlank()) { "pluginId must not be blank" }
  112 + val source = "$SOURCE_PLUGIN_PREFIX$pluginId"
  113 +
  114 + if (!Files.isRegularFile(jarPath)) {
  115 + log.warn(
  116 + "MetadataLoader: plug-in '{}' jarPath {} is not a regular file; skipping (probably loaded as unpacked dir)",
  117 + pluginId, jarPath,
  118 + )
  119 + return doLoad(source, emptyList())
  120 + }
  121 +
  122 + val files = mutableListOf<ParsedYaml>()
  123 + JarFile(jarPath.toFile()).use { jar ->
  124 + val entries = jar.entries()
  125 + while (entries.hasMoreElements()) {
  126 + val entry = entries.nextElement()
  127 + if (entry.isDirectory) continue
  128 + val name = entry.name
  129 + if (!name.startsWith(METADATA_DIR) || !name.endsWith(YAML_SUFFIX)) continue
  130 + try {
  131 + val parsed = jar.getInputStream(entry).use { stream ->
  132 + yamlMapper.readValue(stream, MetadataYamlFile::class.java)
  133 + }
  134 + files += ParsedYaml(url = "$jarPath!$name", parsed = parsed)
  135 + } catch (ex: Throwable) {
  136 + log.error(
  137 + "MetadataLoader: plug-in '{}' failed to parse jar entry {}: {}",
  138 + pluginId, name, ex.message, ex,
  139 + )
  140 + }
  141 + }
  142 + }
  143 +
  144 + return doLoad(source, files)
  145 + }
  146 +
  147 + private fun doLoad(source: String, files: List<ParsedYaml>): LoadResult {
  148 + require(source.isNotBlank()) { "source must not be blank" }
  149 +
  150 + val merged = MetadataYamlFile(
  151 + entities = files.flatMap { it.parsed.entities },
  152 + permissions = files.flatMap { it.parsed.permissions },
  153 + menus = files.flatMap { it.parsed.menus },
  154 + )
  155 +
  156 + wipeBySource(source)
  157 + insertEntities(source, merged)
  158 + insertPermissions(source, merged)
  159 + insertMenus(source, merged)
  160 +
  161 + log.info(
  162 + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus from {} file(s)",
  163 + source, merged.entities.size, merged.permissions.size, merged.menus.size, files.size,
  164 + )
  165 +
  166 + return LoadResult(
  167 + source = source,
  168 + entityCount = merged.entities.size,
  169 + permissionCount = merged.permissions.size,
  170 + menuCount = merged.menus.size,
  171 + files = files.map { it.url },
  172 + )
  173 + }
  174 +
  175 + /**
  176 + * Result of one load() call. Used by callers for logging and by
  177 + * tests for assertions.
  178 + */
  179 + data class LoadResult(
  180 + val source: String,
  181 + val entityCount: Int,
  182 + val permissionCount: Int,
  183 + val menuCount: Int,
  184 + val files: List<String>,
  185 + )
  186 +
  187 + // ─── internals ─────────────────────────────────────────────────
  188 +
  189 + private fun wipeBySource(source: String) {
  190 + val params = MapSqlParameterSource("source", source)
  191 + jdbc.update("DELETE FROM metadata__entity WHERE source = :source", params)
  192 + jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params)
  193 + jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params)
  194 + }
  195 +
  196 + private fun insertEntities(source: String, file: MetadataYamlFile) {
  197 + val now = Timestamp.from(Instant.now())
  198 + for (entity in file.entities) {
  199 + jdbc.update(
  200 + """
  201 + INSERT INTO metadata__entity (id, source, payload, created_at, updated_at)
  202 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  203 + """.trimIndent(),
  204 + MapSqlParameterSource()
  205 + .addValue("id", UUID.randomUUID())
  206 + .addValue("source", source)
  207 + .addValue("payload", jsonMapper.writeValueAsString(entity))
  208 + .addValue("now", now),
  209 + )
  210 + }
  211 + }
  212 +
  213 + private fun insertPermissions(source: String, file: MetadataYamlFile) {
  214 + val now = Timestamp.from(Instant.now())
  215 + for (permission in file.permissions) {
  216 + jdbc.update(
  217 + """
  218 + INSERT INTO metadata__permission (id, source, payload, created_at, updated_at)
  219 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  220 + """.trimIndent(),
  221 + MapSqlParameterSource()
  222 + .addValue("id", UUID.randomUUID())
  223 + .addValue("source", source)
  224 + .addValue("payload", jsonMapper.writeValueAsString(permission))
  225 + .addValue("now", now),
  226 + )
  227 + }
  228 + }
  229 +
  230 + private fun insertMenus(source: String, file: MetadataYamlFile) {
  231 + val now = Timestamp.from(Instant.now())
  232 + for (menu in file.menus) {
  233 + jdbc.update(
  234 + """
  235 + INSERT INTO metadata__menu (id, source, payload, created_at, updated_at)
  236 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  237 + """.trimIndent(),
  238 + MapSqlParameterSource()
  239 + .addValue("id", UUID.randomUUID())
  240 + .addValue("source", source)
  241 + .addValue("payload", jsonMapper.writeValueAsString(menu))
  242 + .addValue("now", now),
  243 + )
  244 + }
  245 + }
  246 +
  247 + private data class ParsedYaml(
  248 + val url: String,
  249 + val parsed: MetadataYamlFile,
  250 + )
  251 +
  252 + companion object {
  253 + const val SOURCE_CORE: String = "core"
  254 + const val SOURCE_PLUGIN_PREFIX: String = "plugin:"
  255 + private const val METADATA_DIR: String = "META-INF/vibe-erp/metadata/"
  256 + private const val YAML_SUFFIX: String = ".yml"
  257 + }
  258 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  5 +import org.springframework.web.bind.annotation.GetMapping
  6 +import org.springframework.web.bind.annotation.RequestMapping
  7 +import org.springframework.web.bind.annotation.RestController
  8 +
  9 +/**
  10 + * Read-only REST endpoint that returns the seeded metadata.
  11 + *
  12 + * Mounted under `/api/v1/_meta/metadata` (next to the existing
  13 + * `/api/v1/_meta/info`) and explicitly **public** — the SPA needs
  14 + * to bootstrap its navigation, the AI-agent function catalog needs
  15 + * to discover entities, and external tooling needs to introspect
  16 + * the framework — all without holding a token. The metadata returned
  17 + * is intentionally non-sensitive: entity names, permission keys, menu
  18 + * paths. Nothing in here should ever be PII or secret.
  19 + *
  20 + * If a future version exposes more sensitive metadata (custom field
  21 + * definitions that mention column names of user data, for example),
  22 + * the controller can be split into a public surface and an
  23 + * authenticated-only surface without breaking the existing route.
  24 + */
  25 +@RestController
  26 +@RequestMapping("/api/v1/_meta/metadata")
  27 +class MetadataController(
  28 + private val jdbc: NamedParameterJdbcTemplate,
  29 + private val objectMapper: ObjectMapper,
  30 +) {
  31 +
  32 + @GetMapping
  33 + fun all(): Map<String, Any> = mapOf(
  34 + "entities" to readPayloads("metadata__entity"),
  35 + "permissions" to readPayloads("metadata__permission"),
  36 + "menus" to readPayloads("metadata__menu"),
  37 + )
  38 +
  39 + @GetMapping("/entities")
  40 + fun entities(): List<Map<String, Any?>> = readPayloads("metadata__entity")
  41 +
  42 + @GetMapping("/permissions")
  43 + fun permissions(): List<Map<String, Any?>> = readPayloads("metadata__permission")
  44 +
  45 + @GetMapping("/menus")
  46 + fun menus(): List<Map<String, Any?>> = readPayloads("metadata__menu")
  47 +
  48 + private fun readPayloads(table: String): List<Map<String, Any?>> {
  49 + return jdbc.query(
  50 + "SELECT source, payload FROM $table ORDER BY source",
  51 + emptyMap<String, Any?>(),
  52 + ) { rs, _ ->
  53 + val source = rs.getString("source")
  54 + val payloadJson = rs.getString("payload") ?: "{}"
  55 + @Suppress("UNCHECKED_CAST")
  56 + val payload = objectMapper.readValue(payloadJson, Map::class.java) as Map<String, Any?>
  57 + payload + mapOf("source" to source)
  58 + }
  59 + }
  60 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.yaml
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonIgnoreProperties
  4 +
  5 +/**
  6 + * The schema of a single metadata file under `META-INF/vibe-erp/metadata/`
  7 + * (any `.yml` filename).
  8 + *
  9 + * **Why one top-level file with multiple typed sections** instead of
  10 + * one file per type:
  11 + * - PBCs and plug-ins typically declare half a dozen tightly-related
  12 + * metadata items at once (an entity, three permissions, one menu
  13 + * entry). Forcing them into separate files would split a single
  14 + * feature across the filesystem and make code review harder.
  15 + * - The top-level keys (`entities`, `permissions`, `menus`) are the
  16 + * extension points. Adding a new key (`forms`, `workflows`, `rules`,
  17 + * `translations`) is non-breaking — old YAML files just don't have
  18 + * the new section, the loader skips it.
  19 + *
  20 + * **Source tagging.** The loader adds a `source` column to every row
  21 + * it persists: `core` for files on the host classpath, `plugin:<id>`
  22 + * for files inside a plug-in JAR, `user` for rows produced by the
  23 + * runtime customization UI (P3.x). The YAML itself never declares its
  24 + * source — that comes from where the file was loaded from, not from
  25 + * its content. Plug-ins cannot declare themselves as `core`.
  26 + *
  27 + * Reference: architecture spec section 8 ("The metadata store").
  28 + */
  29 +@JsonIgnoreProperties(ignoreUnknown = true)
  30 +data class MetadataYamlFile(
  31 + val entities: List<EntityYaml> = emptyList(),
  32 + val permissions: List<PermissionYaml> = emptyList(),
  33 + val menus: List<MenuYaml> = emptyList(),
  34 +)
  35 +
  36 +/**
  37 + * A business entity exposed through the framework.
  38 + *
  39 + * Plug-ins and PBCs declare their entities here so the framework
  40 + * (and the future SPA, MCP server, AI agent function catalog, OpenAPI
  41 + * generator) can introspect what kinds of objects exist without
  42 + * scanning Hibernate or reading source code.
  43 + *
  44 + * @property name Display name. Convention: PascalCase singular ("User",
  45 + * "Item", "PrintingPlate").
  46 + * @property pbc Owning Packaged Business Capability id. Convention:
  47 + * the Gradle subproject name without the `pbc-` prefix
  48 + * (`identity`, `catalog`, …) for core PBCs, the plug-in id for
  49 + * plug-in entities (`printing-shop`).
  50 + * @property table Underlying database table name. Used by the future
  51 + * custom-field machinery and audit log to know which JSONB `ext`
  52 + * column to write into.
  53 + * @property description Free-form one-line description shown in the
  54 + * customization UI and in the OpenAPI spec.
  55 + */
  56 +@JsonIgnoreProperties(ignoreUnknown = true)
  57 +data class EntityYaml(
  58 + val name: String,
  59 + val pbc: String,
  60 + val table: String,
  61 + val description: String? = null,
  62 +)
  63 +
  64 +/**
  65 + * A permission declared by a PBC or a plug-in.
  66 + *
  67 + * Permissions are how the framework's authorization layer (P4.3, not
  68 + * yet implemented) decides whether the current Principal can perform
  69 + * a given action. Declaring them in metadata serves three purposes:
  70 + *
  71 + * 1. The role editor in the SPA can list every available permission.
  72 + * 2. The framework can pre-check that every `@RequirePermission`
  73 + * annotation in code references a declared permission and warn
  74 + * on dangling references at boot.
  75 + * 3. The OpenAPI spec and the AI-agent function catalog can show
  76 + * "this endpoint requires X" without scraping code.
  77 + *
  78 + * @property key The permission key. Convention:
  79 + * `<pbc-or-plugin>.<resource>.<action>`. Lowercase, dot-separated.
  80 + * Examples: `identity.user.create`, `catalog.item.read`,
  81 + * `printingshop.plate.approve`.
  82 + * @property description One-line human-readable description.
  83 + */
  84 +@JsonIgnoreProperties(ignoreUnknown = true)
  85 +data class PermissionYaml(
  86 + val key: String,
  87 + val description: String,
  88 +)
  89 +
  90 +/**
  91 + * A menu entry shown in the SPA's navigation.
  92 + *
  93 + * The framework's nav structure is data, not hard-coded React routes —
  94 + * adding a new top-level menu item from a plug-in is a YAML change,
  95 + * not a code change. The future SPA reads this list from
  96 + * `GET /api/v1/_meta/metadata` at boot and renders the navigation
  97 + * accordingly.
  98 + *
  99 + * @property path SPA route the entry navigates to. Convention:
  100 + * `/<pbc-or-plugin>/<resource>` (e.g. `/identity/users`).
  101 + * @property label Display label. Should be a translation key
  102 + * (`identity.menu.users`) once the i18n loader lands; for now it's
  103 + * a literal English string.
  104 + * @property icon Material/Tabler icon name. The SPA renders it; the
  105 + * framework just stores the string.
  106 + * @property section Top-level section the entry belongs to
  107 + * ("System", "Sales", "Production"). Entries with the same
  108 + * section group together in the nav.
  109 + * @property order Sort order within the section. Lower numbers
  110 + * appear first.
  111 + */
  112 +@JsonIgnoreProperties(ignoreUnknown = true)
  113 +data class MenuYaml(
  114 + val path: String,
  115 + val label: String,
  116 + val icon: String? = null,
  117 + val section: String = "Other",
  118 + val order: Int = 1000,
  119 +)
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.contains
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import io.mockk.every
  9 +import io.mockk.mockk
  10 +import io.mockk.slot
  11 +import io.mockk.verify
  12 +import org.junit.jupiter.api.BeforeEach
  13 +import org.junit.jupiter.api.Test
  14 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  15 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  16 +import java.nio.file.Files
  17 +import java.nio.file.Path
  18 +import java.util.jar.Attributes
  19 +import java.util.jar.JarEntry
  20 +import java.util.jar.JarOutputStream
  21 +import java.util.jar.Manifest
  22 +
  23 +/**
  24 + * Unit tests for [MetadataLoader].
  25 + *
  26 + * Strategy: build a tiny in-memory JAR containing a single
  27 + * `META-INF/vibe-erp/metadata/test.yml` file in a temp directory,
  28 + * load it via a `URLClassLoader`, point the loader at it with a
  29 + * mocked `NamedParameterJdbcTemplate`, and assert on the SQL the
  30 + * loader issues. We don't need a real database — the loader's
  31 + * contract is "given this YAML, issue these statements".
  32 + */
  33 +class MetadataLoaderTest {
  34 +
  35 + private lateinit var jdbc: NamedParameterJdbcTemplate
  36 + private lateinit var loader: MetadataLoader
  37 +
  38 + @BeforeEach
  39 + fun setUp() {
  40 + jdbc = mockk(relaxed = false)
  41 + every { jdbc.update(any<String>(), any<MapSqlParameterSource>()) } returns 1
  42 + loader = MetadataLoader(jdbc)
  43 + }
  44 +
  45 + @Test
  46 + fun `loadFromPluginJar on a missing file is a no-op wipe`() {
  47 + // The plug-in's pluginPath might not be a regular file (e.g.
  48 + // an unpacked dev directory). The loader should still issue
  49 + // the three DELETE statements so a previous-boot's leftovers
  50 + // for this plug-in get cleared, then return a zero result.
  51 + val nowhere = Path.of("/tmp/does-not-exist-${System.nanoTime()}.jar")
  52 +
  53 + val result = loader.loadFromPluginJar("ghost", nowhere)
  54 +
  55 + assertThat(result.entityCount).isEqualTo(0)
  56 + verify(atLeast = 1) {
  57 + jdbc.update(match<String> { it.contains("DELETE FROM metadata__entity") }, any<MapSqlParameterSource>())
  58 + }
  59 + }
  60 +
  61 + @Test
  62 + fun `loadFromPluginJar with one entity issues one INSERT into metadata__entity`() {
  63 + val tempDir = Files.createTempDirectory("metadata-loader-test")
  64 + val jar = buildMetadataJar(
  65 + tempDir,
  66 + "META-INF/vibe-erp/metadata/test.yml",
  67 + """
  68 + entities:
  69 + - name: Plate
  70 + pbc: printing-shop
  71 + table: plugin_printingshop__plate
  72 + description: An offset printing plate
  73 + """.trimIndent(),
  74 + )
  75 +
  76 + val captured = slot<MapSqlParameterSource>()
  77 + every {
  78 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__entity") }, capture(captured))
  79 + } returns 1
  80 +
  81 + val result = loader.loadFromPluginJar("printing-shop", jar)
  82 +
  83 + assertThat(result.entityCount).isEqualTo(1)
  84 + assertThat(result.permissionCount).isEqualTo(0)
  85 + assertThat(result.menuCount).isEqualTo(0)
  86 + assertThat(result.source).isEqualTo("plugin:printing-shop")
  87 +
  88 + val params = captured.captured
  89 + assertThat(params.getValue("source") as String).isEqualTo("plugin:printing-shop")
  90 + val payload = params.getValue("payload") as String
  91 + assertThat(payload).contains("Plate")
  92 + assertThat(payload).contains("printing-shop")
  93 + assertThat(payload).contains("plugin_printingshop__plate")
  94 + }
  95 +
  96 + @Test
  97 + fun `loadFromPluginJar with mixed sections issues inserts into all three tables`() {
  98 + val tempDir = Files.createTempDirectory("metadata-loader-test")
  99 + val jar = buildMetadataJar(
  100 + tempDir,
  101 + "META-INF/vibe-erp/metadata/everything.yml",
  102 + """
  103 + entities:
  104 + - name: User
  105 + pbc: identity
  106 + table: identity__user
  107 + permissions:
  108 + - key: identity.user.read
  109 + description: Read users
  110 + menus:
  111 + - path: /identity/users
  112 + label: Users
  113 + section: System
  114 + order: 100
  115 + """.trimIndent(),
  116 + )
  117 +
  118 + val result = loader.loadFromPluginJar("test-plugin", jar)
  119 +
  120 + assertThat(result.entityCount).isEqualTo(1)
  121 + assertThat(result.permissionCount).isEqualTo(1)
  122 + assertThat(result.menuCount).isEqualTo(1)
  123 + verify(exactly = 1) {
  124 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__entity") }, any<MapSqlParameterSource>())
  125 + }
  126 + verify(exactly = 1) {
  127 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__permission") }, any<MapSqlParameterSource>())
  128 + }
  129 + verify(exactly = 1) {
  130 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__menu") }, any<MapSqlParameterSource>())
  131 + }
  132 + }
  133 +
  134 + @Test
  135 + fun `loadFromPluginJar rejects blank pluginId`() {
  136 + val nowhere = Path.of("/tmp/dummy.jar")
  137 + assertFailure { loader.loadFromPluginJar("", nowhere) }
  138 + .isInstanceOf(IllegalArgumentException::class)
  139 + assertFailure { loader.loadFromPluginJar(" ", nowhere) }
  140 + .isInstanceOf(IllegalArgumentException::class)
  141 + }
  142 +
  143 + // ─── helpers ───────────────────────────────────────────────────
  144 +
  145 + private fun buildMetadataJar(
  146 + tempDir: Path,
  147 + entryName: String,
  148 + contents: String,
  149 + ): Path {
  150 + val jar = tempDir.resolve("metadata-test-${System.nanoTime()}.jar")
  151 + val manifest = Manifest().apply {
  152 + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0"
  153 + }
  154 + Files.newOutputStream(jar).use { fos ->
  155 + JarOutputStream(fos, manifest).use { jos ->
  156 + // Spring's PathMatchingResourcePatternResolver with the
  157 + // `classpath*:` + `*.yml` pattern enumerates parent
  158 + // directories via ClassLoader.getResources(...). JAR files
  159 + // built by Gradle include explicit directory entries; a
  160 + // hand-rolled JAR like this one needs them too or the
  161 + // resolver returns nothing.
  162 + val segments = entryName.split("/").dropLast(1)
  163 + val accumulated = StringBuilder()
  164 + for (segment in segments) {
  165 + accumulated.append(segment).append("/")
  166 + val dirEntry = JarEntry(accumulated.toString())
  167 + jos.putNextEntry(dirEntry)
  168 + jos.closeEntry()
  169 + }
  170 + jos.putNextEntry(JarEntry(entryName))
  171 + jos.write(contents.toByteArray(Charsets.UTF_8))
  172 + jos.closeEntry()
  173 + }
  174 + }
  175 + return jar
  176 + }
  177 +}
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.yaml
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.containsExactly
  5 +import assertk.assertions.hasSize
  6 +import assertk.assertions.isEmpty
  7 +import assertk.assertions.isEqualTo
  8 +import assertk.assertions.isNotNull
  9 +import com.fasterxml.jackson.databind.ObjectMapper
  10 +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
  11 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  12 +import org.junit.jupiter.api.Test
  13 +
  14 +/**
  15 + * Snapshot tests on the YAML deserialization shape. They run in
  16 + * milliseconds and protect against accidental schema changes that
  17 + * would break every plug-in's metadata file.
  18 + */
  19 +class MetadataYamlParseTest {
  20 +
  21 + private val mapper = ObjectMapper(YAMLFactory()).registerKotlinModule()
  22 +
  23 + @Test
  24 + fun `empty document parses to all-empty file`() {
  25 + val parsed = mapper.readValue("{}", MetadataYamlFile::class.java)
  26 + assertThat(parsed.entities).isEmpty()
  27 + assertThat(parsed.permissions).isEmpty()
  28 + assertThat(parsed.menus).isEmpty()
  29 + }
  30 +
  31 + @Test
  32 + fun `document with only entities works`() {
  33 + val yaml = """
  34 + entities:
  35 + - name: User
  36 + pbc: identity
  37 + table: identity__user
  38 + description: A user account
  39 + """.trimIndent()
  40 +
  41 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  42 +
  43 + assertThat(parsed.entities).hasSize(1)
  44 + val e = parsed.entities[0]
  45 + assertThat(e.name).isEqualTo("User")
  46 + assertThat(e.pbc).isEqualTo("identity")
  47 + assertThat(e.table).isEqualTo("identity__user")
  48 + assertThat(e.description).isEqualTo("A user account")
  49 + }
  50 +
  51 + @Test
  52 + fun `entity description is optional`() {
  53 + val yaml = """
  54 + entities:
  55 + - name: Foo
  56 + pbc: bar
  57 + table: bar__foo
  58 + """.trimIndent()
  59 +
  60 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  61 +
  62 + assertThat(parsed.entities).hasSize(1)
  63 + assertThat(parsed.entities[0].description).isEqualTo(null)
  64 + }
  65 +
  66 + @Test
  67 + fun `permissions parse with key + description`() {
  68 + val yaml = """
  69 + permissions:
  70 + - key: identity.user.create
  71 + description: Create a user
  72 + - key: identity.user.delete
  73 + description: Delete a user
  74 + """.trimIndent()
  75 +
  76 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  77 +
  78 + assertThat(parsed.permissions).hasSize(2)
  79 + assertThat(parsed.permissions.map { it.key }).containsExactly(
  80 + "identity.user.create",
  81 + "identity.user.delete",
  82 + )
  83 + }
  84 +
  85 + @Test
  86 + fun `menus default section to Other and order to 1000`() {
  87 + val yaml = """
  88 + menus:
  89 + - path: /x
  90 + label: X
  91 + """.trimIndent()
  92 +
  93 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  94 +
  95 + val m = parsed.menus.single()
  96 + assertThat(m.path).isEqualTo("/x")
  97 + assertThat(m.label).isEqualTo("X")
  98 + assertThat(m.section).isEqualTo("Other")
  99 + assertThat(m.order).isEqualTo(1000)
  100 + assertThat(m.icon).isEqualTo(null)
  101 + }
  102 +
  103 + @Test
  104 + fun `unknown top-level keys are ignored`() {
  105 + // Forward-compat: a future plug-in built against a newer YAML
  106 + // schema (with `forms`, `workflows`, `rules`, …) loads cleanly
  107 + // on an older host that doesn't know those keys yet.
  108 + val yaml = """
  109 + entities:
  110 + - name: Foo
  111 + pbc: bar
  112 + table: bar__foo
  113 + forms:
  114 + - id: future-form
  115 + unknown_section:
  116 + - foo
  117 + """.trimIndent()
  118 +
  119 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  120 +
  121 + assertThat(parsed.entities).hasSize(1)
  122 + }
  123 +
  124 + @Test
  125 + fun `mixed file with all three sections parses`() {
  126 + val yaml = """
  127 + entities:
  128 + - name: Item
  129 + pbc: catalog
  130 + table: catalog__item
  131 + permissions:
  132 + - key: catalog.item.read
  133 + description: Read items
  134 + menus:
  135 + - path: /catalog/items
  136 + label: Items
  137 + section: Catalog
  138 + order: 200
  139 + """.trimIndent()
  140 +
  141 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  142 +
  143 + assertThat(parsed.entities).hasSize(1)
  144 + assertThat(parsed.permissions).hasSize(1)
  145 + assertThat(parsed.menus).hasSize(1)
  146 + assertThat(parsed.menus[0].section).isEqualTo("Catalog")
  147 + assertThat(parsed.menus[0].order).isEqualTo(200)
  148 + }
  149 +}
... ...
platform/platform-plugins/build.gradle.kts
... ... @@ -25,6 +25,7 @@ dependencies {
25 25 implementation(libs.kotlin.reflect)
26 26 implementation(libs.jackson.module.kotlin)
27 27 implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext
  28 + implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...)
28 29  
29 30 implementation(libs.spring.boot.starter)
30 31 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -9,6 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
9 9 import org.springframework.stereotype.Component
10 10 import org.vibeerp.api.v1.event.EventBus
11 11 import org.vibeerp.api.v1.plugin.PluginJdbc
  12 +import org.vibeerp.platform.metadata.MetadataLoader
12 13 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
13 14 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
14 15 import org.vibeerp.platform.plugins.lint.PluginLinter
... ... @@ -56,6 +57,7 @@ class VibeErpPluginManager(
56 57 private val jdbc: PluginJdbc,
57 58 private val linter: PluginLinter,
58 59 private val liquibaseRunner: PluginLiquibaseRunner,
  60 + private val metadataLoader: MetadataLoader,
59 61 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
60 62  
61 63 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
... ... @@ -68,6 +70,18 @@ class VibeErpPluginManager(
68 70 private val started: MutableList<Pair<String, VibeErpPlugin>> = mutableListOf()
69 71  
70 72 override fun afterPropertiesSet() {
  73 + // Load core (host classpath) metadata FIRST, regardless of plug-in
  74 + // settings. The metadata table needs the core entries even if no
  75 + // plug-ins are present, and per-plug-in metadata layers on top.
  76 + try {
  77 + metadataLoader.loadCore()
  78 + } catch (ex: Throwable) {
  79 + log.error(
  80 + "VibeErpPluginManager: core metadata load failed; framework will boot without core metadata",
  81 + ex,
  82 + )
  83 + }
  84 +
71 85 if (!properties.autoLoad) {
72 86 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan")
73 87 return
... ... @@ -131,6 +145,28 @@ class VibeErpPluginManager(
131 145 if (ok) migrated += wrapper
132 146 }
133 147  
  148 + // Load the plug-in's metadata YAMLs from inside its JAR file
  149 + // AFTER migration but BEFORE start. We open the JAR directly
  150 + // (not via the plug-in's classloader) so a parent-first
  151 + // PluginClassLoader cannot pollute the plug-in's metadata
  152 + // with the host's core YAML files. Failure here is logged but
  153 + // does NOT abort the plug-in — metadata is informational.
  154 + for (wrapper in migrated) {
  155 + val jarPath = wrapper.pluginPath
  156 + if (jarPath == null) {
  157 + log.debug("plug-in '{}' has no jarPath; skipping metadata load", wrapper.pluginId)
  158 + continue
  159 + }
  160 + try {
  161 + metadataLoader.loadFromPluginJar(wrapper.pluginId, jarPath)
  162 + } catch (ex: Throwable) {
  163 + log.warn(
  164 + "plug-in '{}' metadata load failed; the plug-in will still start without metadata",
  165 + wrapper.pluginId, ex,
  166 + )
  167 + }
  168 + }
  169 +
134 170 startPlugins()
135 171  
136 172 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
... ...
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml 0 → 100644
  1 +# Printing-shop reference plug-in metadata.
  2 +#
  3 +# Loaded by MetadataLoader at plug-in start, tagged
  4 +# source='plugin:printing-shop'. The framework's REST endpoint at
  5 +# GET /api/v1/_meta/metadata returns these alongside the core entries
  6 +# so the SPA's navigation grows when the plug-in is installed and
  7 +# shrinks when it's uninstalled.
  8 +
  9 +entities:
  10 + - name: Plate
  11 + pbc: printing-shop
  12 + table: plugin_printingshop__plate
  13 + description: A printing plate (offset, flexo, screen) with dimensions and approval status
  14 +
  15 + - name: InkRecipe
  16 + pbc: printing-shop
  17 + table: plugin_printingshop__ink_recipe
  18 + description: A CMYK ink recipe used to mix the actual ink for a print run
  19 +
  20 +permissions:
  21 + - key: printingshop.plate.read
  22 + description: Read plate records
  23 + - key: printingshop.plate.create
  24 + description: Create new plates (initially in DRAFT status)
  25 + - key: printingshop.plate.approve
  26 + description: Approve a plate, moving it from DRAFT to APPROVED
  27 + - key: printingshop.ink.read
  28 + description: Read ink recipes
  29 + - key: printingshop.ink.create
  30 + description: Create new ink recipes
  31 +
  32 +menus:
  33 + - path: /plugins/printing-shop/plates
  34 + label: Plates
  35 + icon: stamp
  36 + section: Printing shop
  37 + order: 500
  38 + - path: /plugins/printing-shop/inks
  39 + label: Inks
  40 + icon: palette
  41 + section: Printing shop
  42 + order: 510
... ...
settings.gradle.kts
... ... @@ -36,6 +36,9 @@ project(&quot;:platform:platform-security&quot;).projectDir = file(&quot;platform/platform-secu
36 36 include(":platform:platform-events")
37 37 project(":platform:platform-events").projectDir = file("platform/platform-events")
38 38  
  39 +include(":platform:platform-metadata")
  40 +project(":platform:platform-metadata").projectDir = file("platform/platform-metadata")
  41 +
39 42 // ─── Packaged Business Capabilities (core PBCs) ─────────────────────
40 43 include(":pbc:pbc-identity")
41 44 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")
... ...