-
Completes the HasExt rollout across every core entity with an ext column. Item was the last one that carried an ext JSONB column without any validation wired — a plug-in could declare custom fields for Item but nothing would enforce them on save. This fixes that and restores two printing-shop-specific Item fields to the reference plug-in that were temporarily dropped from the previous Tier 1 customization chunk (commit 16c59310) precisely because Item wasn't wired. Code changes: - Item implements HasExt; `ext` becomes `override var ext`, a companion constant holds the entity name "Item". - ItemService injects ExtJsonValidator, calls applyTo() in both create() and update() (create + update symmetry like partners and locations). parseExt passthrough added for response mappers. - CreateItemCommand, UpdateItemCommand, CreateItemRequest, UpdateItemRequest gain a nullable ext field. - ItemResponse now carries the parsed ext map, same shape as PartnerResponse / LocationResponse / SalesOrderResponse. - pbc-catalog build.gradle adds `implementation(project(":platform:platform-metadata"))`. - ItemServiceTest constructor updated to pass the new validator dependency with no-op stubs. Plug-in YAML (printing-shop.yml): - Re-added `printing_shop_color_count` (integer) and `printing_shop_paper_gsm` (integer) custom fields targeting Item. These were originally in the commit 16c59310 draft but removed because Item wasn't wired. Now that Item is wired, they're back and actually enforced. Smoke verified end-to-end against real Postgres with the plug-in staged: - GET /_meta/metadata/custom-fields/Item returns 2 plug-in fields. - POST /catalog/items with `{printing_shop_color_count: 4, printing_shop_paper_gsm: 170}` → 201, canonical form persisted. - GET roundtrip preserves both integer values. - POST with `printing_shop_color_count: "not-a-number"` → 400 "ext.printing_shop_color_count: not a valid integer: 'not-a-number'". - POST with `rogue_key` → 400 "ext contains undeclared key(s) for 'Item': [rogue_key]". Six of eight PBCs now participate in HasExt: Partner, Location, SalesOrder, PurchaseOrder, WorkOrder, Item. The remaining two are pbc-identity (User has no ext column by design — identity is a security concern, not a customization one) and pbc-finance (JournalEntry is derived state from events, no customization surface). Five core entities carry Tier 1 custom fields as of this commit: Partner (2 core + 1 plug-in) Item (0 core + 2 plug-in) SalesOrder (0 core + 1 plug-in) WorkOrder (2 core + 1 plug-in) Location (0 core + 0 plug-in — wired but no declarations yet) 246 unit tests, all green. 18 Gradle subprojects.
-
Closes the P4.3 permission-rollout gap for the two oldest PBCs that were never updated when the @RequirePermission aspect landed. The catalog and partners metadata YAMLs already declared all the needed permission keys — the controllers just weren't consuming them. Catalog - ItemController: list/get/getByCode → catalog.item.read; create → catalog.item.create; update → catalog.item.update; deactivate → catalog.item.deactivate. - UomController: list/get/getByCode → catalog.uom.read; create → catalog.uom.create; update → catalog.uom.update. Partners (including the PII boundary) - PartnerController: list/get/getByCode → partners.partner.read; create → partners.partner.create; update → partners.partner.update. (deactivate was already annotated in the P4.3 demo chunk.) - AddressController: all five verbs annotated with partners.address.{read,create,update,delete}. - ContactController: all five verbs annotated with partners.contact.{read,create,update,deactivate}. The "TODO once P4.3 lands" note in the class KDoc was removed; P4.3 is live and the annotations are now in place. This is the PII boundary that CLAUDE.md flagged as incomplete after the original P4.3 rollout. No new permission keys were added — all 14 keys this touches were already declared in pbc-catalog/catalog.yml and pbc-partners/partners.yml when those PBCs were first built. The metadata loader has been serving them to the SPA/OpenAPI/MCP introspection endpoint since day one; this change just starts enforcing them at the controller. Smoke-tested end-to-end against real Postgres - Fresh DB + fresh boot. - Admin happy path (bootstrap admin has wildcard `admin` role): GET /api/v1/catalog/items → 200 POST /api/v1/catalog/items → 201 (SMOKE-1 created) GET /api/v1/catalog/uoms → 200 POST /api/v1/partners/partners → 201 (SMOKE-P created) POST /api/v1/partners/.../contacts → 201 (contact created) GET /api/v1/partners/.../contacts → 200 (PII read) - Anonymous negative path (no Bearer token): GET /api/v1/catalog/items → 401 GET /api/v1/partners/.../contacts → 401 - 230 unit tests still green (annotations are purely additive, no existing test hit the @RequirePermission path since the service-level tests bypass the controller entirely). Why this is a genuine security improvement - Before: any authenticated user (including the eventual "Alice from reception", the contractor's read-only service account, the AI-agent MCP client) could read PII, create partners, and create catalog items. - After: those operations require explicit role-permission grants through metadata__role_permission. The bootstrap admin still has unconditional access via the wildcard admin role, so nothing in a fresh deployment is broken; but a real operator granting minimum-privilege roles now has the columns they need in the database to do it. - The contact PII boundary in particular is GDPR-relevant: before this change, any logged-in user could enumerate every contact's name + email + phone. After, only users with partners.contact.read can see them. What's still NOT annotated - pbc-inventory's Location create/update/deactivate endpoints (only stock.adjust and movement.create are annotated). - pbc-orders-sales and pbc-orders-purchase list/get/create/update endpoints (only the state-transition verbs are annotated). - pbc-identity's user admin endpoints. These are the next cleanup chunk. This one stays focused on catalog + partners because those were the two PBCs that predated P4.3 entirely and hadn't been touched since.
-
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. -
Adds the second core PBC, validating that the pbc-identity template is actually clonable and that the Gradle dependency rule fires correctly for a real second PBC. What landed: * New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity: api-v1 + platform-persistence + platform-security only (no platform-bootstrap, no other pbc). The architecture rule in the root build.gradle.kts now has two real PBCs to enforce against. * `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb. Code is the natural key (stable, human-readable). UomService rejects duplicate codes and refuses to update the code itself (would invalidate every Item FK referencing it). UomController at /api/v1/catalog/uoms exposes list, get-by-id, get-by-code, create, update. * `Item` entity (catalog__item) — code, name, description, item_type (GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb. ItemService validates the referenced UoM exists at the application layer (better error message than the DB FK alone), refuses to update code or baseUomCode (data-migration operations, not edits), supports soft delete via deactivate. ItemController at /api/v1/catalog/items with full CRUD. * `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade in api.v1 (after IdentityApi). Exposes findItemByCode(code) and findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items are filtered to null at the boundary so callers cannot accidentally reference deactivated catalog rows. * `CatalogApiAdapter` in pbc-catalog — concrete @Component implementing CatalogApi. Maps internal entities to api.v1 DTOs without leaking storage types. * Liquibase changeset (catalog-init-001..003) creates both tables with unique indexes on code, GIN indexes on ext, and seeds 15 canonical units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area), l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged created_by='__seed__' so a future metadata uninstall sweep can identify them. Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6), total now 49 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres via docker-compose (14/14 passing): GET /api/v1/catalog/items (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/catalog/uoms (Bearer) → 15 seeded UoMs GET /api/v1/catalog/uoms/by-code/kg → 200 POST custom UoM 'roll' → 201 POST duplicate UoM 'kg' → 400 + clear message GET items → [] POST item with unknown UoM → 400 + clear message POST item with valid UoM → 201 catalog__item.created_by → admin user UUID (NOT __system__) GET /by-code/INK-CMYK-CYAN → 200 PATCH item name + description → 200 DELETE item → 204 GET item → active=false The principal-context bridge from P4.1 keeps working without any additional wiring in pbc-catalog: every PBC inherits the audit behavior for free by extending AuditedJpaEntity. That is exactly the "PBCs follow a recipe, the framework provides the cross-cutting machinery" promise from the architecture spec. Architectural rule enforcement still active: confirmed by reading the build.gradle.kts and observing that pbc-catalog declares no :platform:platform-bootstrap and no :pbc:pbc-identity dependency. The build refuses to load on either violation.