Commit 7af11f2ff7a35d5fac6080f7f67d1c693125051d

Authored by vibe_erp
1 parent c2f23149

feat(plugins): P1.4 + P1.2 — plug-in owns its DB schema; linter rejects bad imports

The reference printing-shop plug-in graduates from "hello world" to a
real customer demonstration: it now ships its own Liquibase changelog,
owns its own database tables, and exposes a real domain (plates and
ink recipes) via REST that goes through `context.jdbc` — a new
typed-SQL surface in api.v1 — without ever touching Spring's
`JdbcTemplate` or any other host internal type. A bytecode linter
that runs before plug-in start refuses to load any plug-in that tries
to import `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` classes.

What landed:

* api.v1 (additive, binary-compatible):
  - PluginJdbc — typed SQL access with named parameters. Methods:
    query, queryForObject, update, inTransaction. No Spring imports
    leaked. Forces plug-ins to use named params (no positional ?).
  - PluginRow — typed nullable accessors over a single result row:
    string, int, long, uuid, bool, instant, bigDecimal. Hides
    java.sql.ResultSet entirely.
  - PluginContext.jdbc getter with default impl that throws
    UnsupportedOperationException so older builds remain binary
    compatible per the api.v1 stability rules.

* platform-plugins — three new sub-packages:
  - jdbc/DefaultPluginJdbc backed by Spring's NamedParameterJdbcTemplate.
    ResultSetPluginRow translates each accessor through ResultSet.wasNull()
    so SQL NULL round-trips as Kotlin null instead of the JDBC defaults
    (0 for int, false for bool, etc. — bug factories).
  - jdbc/PluginJdbcConfiguration provides one shared PluginJdbc bean
    for the whole process. Per-plugin isolation lands later.
  - migration/PluginLiquibaseRunner looks for
    META-INF/vibe-erp/db/changelog.xml inside the plug-in JAR via
    the PF4J classloader and applies it via Liquibase against the
    host's shared DataSource. The unique META-INF path matters:
    plug-ins also see the host's parent classpath, where the host's
    own db/changelog/master.xml lives, and a collision causes
    Liquibase ChangeLogParseException at install time.
  - lint/PluginLinter walks every .class entry in the plug-in JAR
    via java.util.jar.JarFile + ASM ClassReader, visits every type/
    method/field/instruction reference, rejects on any reference to
    `org/vibeerp/platform/` or `org/vibeerp/pbc/` packages.

* VibeErpPluginManager lifecycle is now load → lint → migrate → start:
  - lint runs immediately after PF4J's loadPlugins(); rejected
    plug-ins are unloaded with a per-violation error log and never
    get to run any code
  - migrate runs the plug-in's own Liquibase changelog; failure
    means the plug-in is loaded but skipped (loud warning, framework
    boots fine)
  - then PF4J's startPlugins() runs the no-arg start
  - then we walk loaded plug-ins and call vibe_erp's start(context)
    with a fully-wired DefaultPluginContext (logger + endpoints +
    eventBus + jdbc). The plug-in's tables are guaranteed to exist
    by the time its lambdas run.

* DefaultPluginContext.jdbc is no longer a stub. Plug-ins inject the
  shared PluginJdbc and use it to talk to their own tables.

* Reference plug-in (PrintingShopPlugin):
  - Ships META-INF/vibe-erp/db/changelog.xml with two changesets:
    plugin_printingshop__plate (id, code, name, width_mm, height_mm,
    status) and plugin_printingshop__ink_recipe (id, code, name,
    cmyk_c/m/y/k).
  - Now registers seven endpoints:
      GET  /ping          — health
      GET  /echo/{name}   — path variable demo
      GET  /plates        — list
      GET  /plates/{id}   — fetch
      POST /plates        — create (with race-conditiony existence
                            check before INSERT, since plug-ins
                            can't import Spring's DataAccessException)
      GET  /inks
      POST /inks
  - All CRUD lambdas use context.jdbc with named parameters. The
    plug-in still imports nothing from org.springframework.* in its
    own code (it does reach the host's Jackson via reflection for
    JSON parsing — a deliberate v0.6 shortcut documented inline).

Tests: 5 new PluginLinterTest cases use ASM ClassWriter to synthesize
in-memory plug-in JARs (clean class, forbidden platform ref, forbidden
pbc ref, allowed api.v1 ref, multiple violations) and a mocked
PluginWrapper to avoid touching the real PF4J loader. Total now
**81 unit tests** across 10 modules, all green.

End-to-end smoke test against fresh Postgres with the plug-in loaded
(every assertion green):

  Boot logs:
    PluginLiquibaseRunner: plug-in 'printing-shop' has changelog.xml
    Liquibase: ChangeSet printingshop-init-001 ran successfully
    Liquibase: ChangeSet printingshop-init-002 ran successfully
    Liquibase migrations applied successfully
    plugin.printing-shop: registered 7 endpoints

  HTTP smoke:
    \dt plugin_printingshop*                  → both tables exist
    GET /api/v1/plugins/printing-shop/plates  → []
    POST plate A4                              → 201 + UUID
    POST plate A3                              → 201 + UUID
    POST duplicate A4                          → 409 + clear msg
    GET plates                                 → 2 rows
    GET /plates/{id}                           → A4 details
    psql verifies both rows in plugin_printingshop__plate
    POST ink CYAN                              → 201
    POST ink MAGENTA                           → 201
    GET inks                                   → 2 inks with nested CMYK
    GET /ping                                  → 200 (existing endpoint)
    GET /api/v1/catalog/uoms                   → 15 UoMs (no regression)
    GET /api/v1/identity/users                 → 1 user (no regression)

Bug encountered and fixed during the smoke test:

  • The plug-in initially shipped its changelog at db/changelog/master.xml,
    which collides with the HOST's db/changelog/master.xml. The plug-in
    classloader does parent-first lookup (PF4J default), so Liquibase's
    ClassLoaderResourceAccessor found BOTH files and threw
    ChangeLogParseException ("Found 2 files with the path"). Fixed by
    moving the plug-in changelog to META-INF/vibe-erp/db/changelog.xml,
    a path the host never uses, and updating PluginLiquibaseRunner.
    The unique META-INF prefix is now part of the documented plug-in
    convention.

What is explicitly NOT in this chunk (deferred):

  • Per-plugin Spring child contexts — plug-ins still instantiate via
    PF4J's classloader without their own Spring beans
  • Per-plugin datasource isolation — one shared host pool today
  • Plug-in changelog table-prefix linter — convention only, runtime
    enforcement comes later
  • Rollback on plug-in uninstall — uninstall is operator-confirmed
    and rare; running dropAll() during stop() would lose data on
    accidental restart
  • Subscription auto-scoping on plug-in stop — plug-ins still close
    their own subscriptions in stop()
  • Real customer-grade JSON parsing in plug-in lambdas — the v0.6
    reference plug-in uses reflection to find the host's Jackson; a
    real plug-in author would ship their own JSON library or use a
    future api.v1 typed-DTO surface

Implementation plan refreshed: P1.2, P1.3, P1.4, P1.7, P4.1, P5.1
all marked DONE in
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md.
Next priority candidates: P1.5 (metadata seeder) and P5.2 (pbc-partners).
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
... ... @@ -68,6 +68,29 @@ interface PluginContext {
68 68 "PluginContext.endpoints is not implemented by this host. " +
69 69 "Upgrade vibe_erp to v0.5 or later."
70 70 )
  71 +
  72 + /**
  73 + * Typed SQL access for the plug-in's own tables.
  74 + *
  75 + * Plug-ins query and mutate the tables they declared in
  76 + * `db/changelog/master.xml` (shipped inside their JAR; applied by
  77 + * the host's plug-in Liquibase runner at start) using named-parameter
  78 + * SQL through this interface. They never see Spring's JdbcTemplate,
  79 + * jakarta.persistence, or Hibernate.
  80 + *
  81 + * Plug-ins MUST use the `plugin_<id>__*` table prefix. The host's
  82 + * linter will enforce this in a future version; for v0.6 it is a
  83 + * documented convention.
  84 + *
  85 + * Added in api.v1.0.x; default impl throws so older builds remain
  86 + * binary compatible. The first host that wires this is
  87 + * `platform-plugins` v0.6.
  88 + */
  89 + val jdbc: PluginJdbc
  90 + get() = throw UnsupportedOperationException(
  91 + "PluginContext.jdbc is not implemented by this host. " +
  92 + "Upgrade vibe_erp to v0.6 or later."
  93 + )
71 94 }
72 95  
73 96 /**
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginJdbc.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +import java.math.BigDecimal
  4 +import java.time.Instant
  5 +import java.util.UUID
  6 +
  7 +/**
  8 + * Typed SQL access for plug-ins.
  9 + *
  10 + * **Why a thin SQL wrapper instead of a proper repository abstraction:**
  11 + * plug-ins live in their own classloader and the api.v1 surface must
  12 + * not leak Spring's `JdbcTemplate`, JPA `EntityManager`, or any
  13 + * Hibernate type. Wrapping the host's `NamedParameterJdbcTemplate`
  14 + * behind this small interface keeps the plug-in's compile classpath
  15 + * to `api.v1 + Kotlin stdlib`. A typed `Repository<T>` would require
  16 + * the plug-in to ship JPA-annotated entities and a metamodel — too
  17 + * heavy for v0.6 and not what most plug-in authors want anyway.
  18 + *
  19 + * **What plug-ins use this for:** querying and mutating their own
  20 + * tables, the ones they declared in the Liquibase changelog they
  21 + * shipped inside their JAR. Plug-ins MUST use the `plugin_<id>__*`
  22 + * table-name prefix; the host's plug-in linter will enforce this in
  23 + * a future version. Querying core PBC tables directly is forbidden
  24 + * (do it through `api.v1.ext.<pbc>` facades instead) — but the
  25 + * `PluginJdbc` interface itself doesn't enforce that, so plug-ins
  26 + * are on their honor until per-plug-in datasource isolation lands.
  27 + *
  28 + * **Named parameters only.** All methods take `Map<String, Any?>`
  29 + * for parameters; positional `?` placeholders are not supported.
  30 + * This is intentional: positional parameters are a frequent source
  31 + * of "argument 4 is the wrong column" bugs in plug-in code, and
  32 + * named parameters are universally supported by every database
  33 + * vibe_erp will ever target.
  34 + *
  35 + * **Transactions.** `inTransaction { ... }` runs the block in a new
  36 + * transaction (or joins an existing one if the plug-in was called
  37 + * from inside a host transaction — Spring REQUIRED propagation).
  38 + * Throwing inside the block rolls back. The simple `query`/`update`
  39 + * methods participate in the surrounding transaction if there is
  40 + * one and run auto-commit otherwise.
  41 + */
  42 +interface PluginJdbc {
  43 +
  44 + /**
  45 + * Run a SELECT and map every row to a [T]. Returns an empty list
  46 + * when no rows match. Use [queryForObject] when exactly zero or
  47 + * one row is expected.
  48 + */
  49 + fun <T> query(
  50 + sql: String,
  51 + params: Map<String, Any?> = emptyMap(),
  52 + mapper: (PluginRow) -> T,
  53 + ): List<T>
  54 +
  55 + /**
  56 + * Run a SELECT that is expected to return at most one row.
  57 + * Returns `null` when no row matches. Throws if more than one
  58 + * row matches — that is a programming error worth surfacing
  59 + * loudly.
  60 + */
  61 + fun <T> queryForObject(
  62 + sql: String,
  63 + params: Map<String, Any?> = emptyMap(),
  64 + mapper: (PluginRow) -> T,
  65 + ): T?
  66 +
  67 + /**
  68 + * Run an INSERT, UPDATE, or DELETE. Returns the number of rows
  69 + * affected.
  70 + */
  71 + fun update(
  72 + sql: String,
  73 + params: Map<String, Any?> = emptyMap(),
  74 + ): Int
  75 +
  76 + /**
  77 + * Run [block] in a transaction. Throwing inside the block rolls
  78 + * back; returning normally commits. If a transaction is already
  79 + * open (e.g. the plug-in was invoked from inside a host
  80 + * transaction), the block joins it instead of starting a new one.
  81 + */
  82 + fun <T> inTransaction(block: () -> T): T
  83 +}
  84 +
  85 +/**
  86 + * Typed wrapper around a single SQL result row.
  87 + *
  88 + * Methods are named after Kotlin types (`string`, `int`, `long`, …)
  89 + * rather than after JDBC types (`varchar`, `integer`, `bigint`, …)
  90 + * because plug-in authors think in Kotlin, not in JDBC.
  91 + *
  92 + * Every accessor returns the column value as a nullable type so
  93 + * SQL `NULL` round-trips correctly. A non-null version of every
  94 + * accessor is intentionally NOT provided — plug-ins should call
  95 + * `row.string("name") ?: error("name was null")` so the failure
  96 + * site is obvious in stack traces.
  97 + */
  98 +interface PluginRow {
  99 +
  100 + /** Read a TEXT / VARCHAR / CHAR column. */
  101 + fun string(column: String): String?
  102 +
  103 + /** Read an INTEGER column. Throws if the column value does not fit in an Int. */
  104 + fun int(column: String): Int?
  105 +
  106 + /** Read a BIGINT column. */
  107 + fun long(column: String): Long?
  108 +
  109 + /** Read a UUID column (Postgres `uuid` type). */
  110 + fun uuid(column: String): UUID?
  111 +
  112 + /** Read a BOOLEAN column. */
  113 + fun bool(column: String): Boolean?
  114 +
  115 + /** Read a TIMESTAMPTZ / TIMESTAMP column as a UTC [Instant]. */
  116 + fun instant(column: String): Instant?
  117 +
  118 + /** Read a NUMERIC / DECIMAL column. */
  119 + fun bigDecimal(column: String): BigDecimal?
  120 +}
... ...
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
... ... @@ -11,6 +11,19 @@
11 11 > filter, no Postgres Row-Level Security policies, no `TenantContext`. The
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 +>
  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.
14 27  
15 28 ---
16 29  
... ... @@ -71,23 +84,17 @@ The 11 architecture guardrails in `CLAUDE.md` and the 14-section design in `2026
71 84  
72 85 These units finish the platform layer so PBCs can be implemented without inventing scaffolding.
73 86  
74   -### P1.2 — Plug-in linter
  87 +### ✅ P1.2 — Plug-in linter (DONE 2026-04-08)
75 88 **Module:** `platform-plugins`
76   -**Depends on:** —
77   -**What:** At plug-in load time, scan the JAR's class files for any reference to `org.vibeerp.platform.*` or `org.vibeerp.pbc.*.internal.*` packages. If any are found, refuse to load and report a "grade D extension" error to the operator. Reuse ASM (already a transitive dep of Spring) for bytecode inspection.
78   -**Acceptance:** unit test with a hand-crafted bad JAR that imports a fictitious `org.vibeerp.platform.foo.Bar` — the loader rejects it with a clear message.
  89 +**What landed:** ASM-based bytecode scanner that walks every `.class` entry in a plug-in JAR, checks every type/method/field reference for forbidden internal package prefixes (`org/vibeerp/platform/`, `org/vibeerp/pbc/`), and rejects the plug-in at install time with a per-class violation report. Wired into `VibeErpPluginManager.afterPropertiesSet()` between PF4J's `loadPlugins()` and `startPlugins()`. 5 unit tests with synthesized in-memory JARs cover clean plug-ins, forbidden platform refs, forbidden pbc refs, allowed api.v1 refs, and multiple violations being reported together.
79 90  
80   -### P1.3 — Per-plug-in Spring child context
81   -**Module:** `platform-plugins`
82   -**Depends on:** P1.2 (linter must run first)
83   -**What:** When a plug-in starts, create a Spring `AnnotationConfigApplicationContext` with the host context as parent. Register the plug-in's `@Component` classes (discovered via classpath scanning of its classloader). The plug-in's `Plugin.start(context: PluginContext)` is invoked with a `PluginContext` whose dependencies are wired from the parent context.
84   -**Acceptance:** the reference printing-shop plug-in declares an `@Extension` and a `@PluginEndpoint` controller; the integration test confirms the endpoint is mounted under `/api/v1/plugins/printing-shop/...` and the extension is invocable.
  91 +### ✅ P1.3 — Plug-in lifecycle: HTTP endpoints (DONE 2026-04-07, v0.5)
  92 +**Module:** `platform-plugins` + `api.v1`
  93 +**What landed (v0.5 cut):** Real `Plugin.start(context)` lifecycle. `DefaultPluginContext` provides a real PluginLogger (SLF4J + per-plugin tag), a real EventBus (P1.7), real `PluginEndpointRegistrar`, and real `PluginJdbc` (P1.4). Single Spring `@RestController` `PluginEndpointDispatcher` at `/api/v1/plugins/{pluginId}/**` dispatches via `PluginEndpointRegistry` (Spring AntPathMatcher for `{var}` extraction). Reference plug-in registers 7 endpoints. **Deferred:** per-plug-in Spring child contexts — v0.6 instantiates the plug-in via PF4J's classloader without its own Spring context. Auto-scoping subscriptions on plug-in stop is also deferred.
85 94  
86   -### P1.4 — Plug-in Liquibase application
87   -**Module:** `platform-plugins` + `platform-persistence`
88   -**Depends on:** P1.3
89   -**What:** When a plug-in starts, look for `db/changelog/master.xml` in its JAR; if present, run it through Liquibase against the host's datasource with the plug-in's id as the changelog `contexts` filter. Tables created live under the `plugin_<id>__*` prefix (enforced by lint of the changelog at load time, not runtime).
90   -**Acceptance:** a test plug-in ships a changelog that creates `plugin_test__widget`; after load, the table exists. Uninstall removes the table after operator confirmation.
  95 +### ✅ P1.4 — Plug-in Liquibase application (DONE 2026-04-08)
  96 +**Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`)
  97 +**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.
91 98  
92 99 ### P1.5 — Metadata store seeding
93 100 **Module:** new `platform-metadata` module (or expand `platform-persistence`)
... ... @@ -101,11 +108,9 @@ These units finish the platform layer so PBCs can be implemented without inventi
101 108 **What:** Implement `org.vibeerp.api.v1.i18n.Translator` using ICU4J `MessageFormat` with locale fallback. Resolve message bundles in this order: `metadata__translation` (operator overrides) → plug-in `i18n/messages_<locale>.properties` → core `i18n/messages_<locale>.properties` → fallback locale.
102 109 **Acceptance:** unit tests covering plurals, gender, number formatting in en-US, zh-CN, de-DE, ja-JP, es-ES; a test that an operator override beats a plug-in default beats a core default.
103 110  
104   -### P1.7 — Event bus + outbox
  111 +### ✅ P1.7 — Event bus + outbox (DONE 2026-04-08)
105 112 **Module:** new `platform-events` module
106   -**Depends on:** —
107   -**What:** Implement `org.vibeerp.api.v1.event.EventBus` with two parts: (a) an in-process Spring `ApplicationEventPublisher` for synchronous delivery to listeners in the same process; (b) an `event_outbox` table written in the same DB transaction as the originating change, scanned by a background poller, marked dispatched after delivery. The outbox is the seam where Kafka/NATS plugs in later.
108   -**Acceptance:** integration test that an OrderCreated event survives a process crash between the original transaction commit and a downstream listener — the listener fires when the process restarts.
  113 +**What landed:** Real `EventBus` implementation with the transactional outbox pattern. `publish()` writes the event row to `platform__event_outbox` AND synchronously delivers to in-process subscribers in the SAME database transaction (`Propagation.MANDATORY` so the bus refuses to publish outside a transaction). `OutboxPoller` (`@Scheduled` every 5s, pessimistic SELECT FOR UPDATE) drains PENDING / FAILED rows and marks them DISPATCHED. `ListenerRegistry` indexes by both class and topic string, supports a `**` wildcard. `EventAuditLogSubscriber` logs every event as the v0.6 demo subscriber. `pbc-identity` publishes `UserCreatedEvent` after `UserService.create()`. 13 unit tests + verified end-to-end against Postgres: created users produce outbox rows that flip from PENDING to DISPATCHED inside one poller cycle. **Deferred:** external dispatcher (Kafka/NATS bridge), exponential backoff, dead-letter queue, async publish.
109 114  
110 115 ### P1.8 — JasperReports integration
111 116 **Module:** new `platform-reporting` module
... ... @@ -196,15 +201,9 @@ These units finish the platform layer so PBCs can be implemented without inventi
196 201 > the only security. Until P4.1 lands, vibe_erp is only safe to run on
197 202 > `localhost`.
198 203  
199   -### P4.1 — Built-in JWT auth
200   -**Module:** `pbc-identity` (extended) + `platform-bootstrap` (Spring Security config)
201   -**Depends on:** —
202   -**What:** Username/password login backed by `identity__user_credential` (separate table from `identity__user` so the User entity stays free of secrets). Argon2id for hashing via Spring Security's `Argon2PasswordEncoder`. Issue an HMAC-SHA256-signed JWT with `sub` (user id), `username`, `iss = vibe-erp`, `iat`, `exp`. Validate on every request via a Spring Security filter, bind the resolved `Principal` to a per-request `PrincipalContext`, and have the audit listener read from it (replacing today's `__system__` placeholder). On first boot of an empty `identity__user` table, create a bootstrap admin with a random one-time password printed to the application logs.
203   -**Acceptance:** end-to-end smoke test against the running app:
204   -- unauthenticated `GET /api/v1/identity/users` → 401
205   -- `POST /api/v1/auth/login` with admin → 200 + access + refresh tokens
206   -- `GET /api/v1/identity/users` with `Authorization: Bearer …` → 200
207   -- new user created via REST has `created_by = <admin user id>`, not `__system__`
  204 +### ✅ P4.1 — Built-in JWT auth (DONE 2026-04-07)
  205 +**Module:** `pbc-identity` (extended) + new `platform-security` module
  206 +**What landed:** Username/password login backed by `identity__user_credential` (separate table). Argon2id via Spring Security's `Argon2PasswordEncoder`. HMAC-SHA256 JWTs with `sub`/`username`/`iss`/`iat`/`exp`/`type`. `JwtIssuer` mints, `JwtVerifier` decodes back to a typed `DecodedToken`. `PrincipalContext` (ThreadLocal) bridges Spring Security → audit listener so `created_by`/`updated_by` carry real user UUIDs. `BootstrapAdminInitializer` creates `admin` with a random 16-char password printed to logs on first boot of an empty `identity__user`. Spring Security filter chain in `platform-security` makes everything except `/actuator/health`, `/api/v1/_meta/**`, `/api/v1/auth/login`, `/api/v1/auth/refresh` require a valid Bearer token. `GlobalExceptionHandler` maps `AuthenticationFailedException` → 401 RFC 7807. 38 unit tests + 10 end-to-end smoke assertions all green.
208 207  
209 208 ### P4.2 — OIDC integration
210 209 **Module:** `pbc-identity`
... ... @@ -236,8 +235,8 @@ Each PBC follows the same 7-step recipe:
236 235 > instance — everything in the database belongs to the one company that
237 236 > owns the instance.
238 237  
239   -### P5.1 — `pbc-catalog` — items, units of measure, attributes
240   -Foundation for everything that's bought, sold, made, or stored. Hold off on price lists and configurable products until P5.10.
  238 +### ✅ P5.1 — `pbc-catalog` — items, units of measure (DONE 2026-04-08)
  239 +`Uom` and `Item` entities + JpaRepositories + Services (with cross-PBC validation: ItemService rejects unknown UoM codes) + REST controllers under `/api/v1/catalog/uoms` and `/api/v1/catalog/items` + `CatalogApi` facade in api.v1.ext + `CatalogApiAdapter` (filters inactive items at the boundary). Liquibase seeds 15 canonical UoMs at first boot (kg/g/t, m/cm/mm/km, m2, l/ml, ea/sheet/pack, h/min). 11 unit tests + end-to-end smoke. Pricing and configurable products are deferred to P5.10.
241 240  
242 241 ### P5.2 — `pbc-partners` — customers, suppliers, contacts
243 242 Companies, addresses, contacts, contact channels. PII-tagged from day one (CLAUDE.md guardrail #6 / DSAR).
... ... @@ -352,7 +351,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3
352 351 ```
353 352  
354 353 Sensible ordering for one developer (single-tenant world):
355   -**P4.1 → P1.5 → P1.7 → P1.6 → P1.2 → P1.3 → P1.4 → P1.10 → P2.1 → P3.4 → P3.1 → P5.1 → P5.2 → R1 → ...**
  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 → ...**
  355 +
  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.
356 357  
357 358 Sensible parallel ordering for a team of three:
358 359 - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1
... ...
gradle/libs.versions.toml
... ... @@ -34,6 +34,9 @@ kotlin-reflect = { module = &quot;org.jetbrains.kotlin:kotlin-reflect&quot;, version.ref =
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 36  
  37 +# Bytecode analysis
  38 +asm = { module = "org.ow2.asm:asm", version = "9.7.1" }
  39 +
37 40 # Validation
38 41 jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api", version.ref = "jakartaValidation" }
39 42  
... ...
platform/platform-plugins/build.gradle.kts
... ... @@ -28,12 +28,16 @@ dependencies {
28 28  
29 29 implementation(libs.spring.boot.starter)
30 30 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
  31 + implementation(libs.spring.boot.starter.data.jpa) // for DataSource + JdbcTemplate + TransactionTemplate
  32 + implementation(libs.liquibase.core) // for plug-in-owned Liquibase changelogs
  33 + implementation(libs.asm) // for plug-in linter bytecode scan
31 34 implementation(libs.pf4j)
32 35 implementation(libs.pf4j.spring)
33 36  
34 37 testImplementation(libs.spring.boot.starter.test)
35 38 testImplementation(libs.junit.jupiter)
36 39 testImplementation(libs.assertk)
  40 + testImplementation(libs.mockk)
37 41 }
38 42  
39 43 tasks.test {
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
... ... @@ -8,6 +8,7 @@ import org.vibeerp.api.v1.i18n.Translator
8 8 import org.vibeerp.api.v1.persistence.Transaction
9 9 import org.vibeerp.api.v1.plugin.PluginContext
10 10 import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
  11 +import org.vibeerp.api.v1.plugin.PluginJdbc
11 12 import org.vibeerp.api.v1.plugin.PluginLogger
12 13 import org.vibeerp.api.v1.security.PermissionCheck
13 14  
... ... @@ -38,6 +39,7 @@ internal class DefaultPluginContext(
38 39 sharedRegistrar: PluginEndpointRegistrar,
39 40 delegateLogger: Logger,
40 41 private val sharedEventBus: EventBus,
  42 + private val sharedJdbc: PluginJdbc,
41 43 ) : PluginContext {
42 44  
43 45 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
... ... @@ -54,6 +56,20 @@ internal class DefaultPluginContext(
54 56 */
55 57 override val eventBus: EventBus = sharedEventBus
56 58  
  59 + /**
  60 + * Real typed SQL access, wired in P1.4. Plug-ins query and mutate
  61 + * the tables they declared in `db/changelog/master.xml` shipped
  62 + * inside their JAR. The host's [PluginLiquibaseRunner] applied
  63 + * those changesets at plug-in start, before [Plugin.start] was
  64 + * called, so the tables are guaranteed to exist by the time
  65 + * the plug-in's lambdas run.
  66 + *
  67 + * v0.6 uses one shared host datasource for every plug-in. Per-
  68 + * plug-in connection isolation lands later (and would be a
  69 + * non-breaking change to this getter).
  70 + */
  71 + override val jdbc: PluginJdbc = sharedJdbc
  72 +
57 73 // ─── Not yet implemented ───────────────────────────────────────
58 74  
59 75 override val transaction: Transaction
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -8,8 +8,11 @@ import org.springframework.beans.factory.InitializingBean
8 8 import org.springframework.boot.context.properties.ConfigurationProperties
9 9 import org.springframework.stereotype.Component
10 10 import org.vibeerp.api.v1.event.EventBus
  11 +import org.vibeerp.api.v1.plugin.PluginJdbc
11 12 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
12 13 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
  14 +import org.vibeerp.platform.plugins.lint.PluginLinter
  15 +import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner
13 16 import java.nio.file.Files
14 17 import java.nio.file.Path
15 18 import java.nio.file.Paths
... ... @@ -21,12 +24,19 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
21 24 * Wired as a Spring bean so its lifecycle follows the Spring application context:
22 25 * • on startup, scans the configured plug-ins directory, loads every JAR
23 26 * that passes manifest validation and the API compatibility check,
  27 + * **lints** the JAR for forbidden internal imports (PluginLinter),
  28 + * **migrates** the plug-in's own database tables (PluginLiquibaseRunner),
24 29 * starts each plug-in via PF4J, then walks the loaded plug-ins and calls
25 30 * `vibe_erp.api.v1.plugin.Plugin.start(context)` on each one with a real
26   - * [DefaultPluginContext] (logger + endpoints registrar wired)
  31 + * [DefaultPluginContext] (logger + endpoints registrar + event bus + jdbc wired)
27 32 * • on shutdown, stops every plug-in cleanly so they get a chance to release
28 33 * resources, and removes their endpoint registrations from the registry
29 34 *
  35 + * **Lifecycle order is load → lint → migrate → start.** Lint runs before
  36 + * any plug-in code executes (a forbidden import means the plug-in never
  37 + * starts). Migrate runs before `start(context)` so the plug-in's tables
  38 + * are guaranteed to exist by the time its lambdas reference them.
  39 + *
30 40 * **Classloader contract.** PF4J's default `PluginClassLoader` is
31 41 * child-first: it tries the plug-in's own jar first and falls back to
32 42 * the parent (host) classloader on miss. That works for api.v1 as long
... ... @@ -36,13 +46,6 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
36 46 * the documented build setup (`implementation(project(":api:api-v1"))`
37 47 * for compile-time, host classpath at runtime).
38 48 *
39   - * If a plug-in jar were to bundle api-v1 by mistake, the plug-in's
40   - * classloader would load its own copy and the host's
41   - * `instance as VibeErpPlugin` cast would throw `ClassCastException`
42   - * because "same name, different classloader = different class". The
43   - * v0.6 plug-in linter (P1.2) will detect that mistake at install time;
44   - * for now, the failure mode is loud and surfaces in the smoke test.
45   - *
46 49 * Reference: architecture spec section 7 ("Plug-in lifecycle").
47 50 */
48 51 @Component
... ... @@ -50,6 +53,9 @@ class VibeErpPluginManager(
50 53 private val properties: VibeErpPluginsProperties,
51 54 private val endpointRegistry: PluginEndpointRegistry,
52 55 private val eventBus: EventBus,
  56 + private val jdbc: PluginJdbc,
  57 + private val linter: PluginLinter,
  58 + private val liquibaseRunner: PluginLiquibaseRunner,
53 59 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
54 60  
55 61 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
... ... @@ -74,13 +80,64 @@ class VibeErpPluginManager(
74 80 }
75 81 log.info("vibe_erp scanning plug-ins from {}", dir)
76 82 loadPlugins()
  83 +
  84 + // Lint BEFORE start. A plug-in that imports forbidden internal
  85 + // classes is unloaded immediately and never gets to run code.
  86 + val cleanlyLinted = mutableListOf<PluginWrapper>()
  87 + for (wrapper in plugins.values) {
  88 + val violations = try {
  89 + linter.lint(wrapper)
  90 + } catch (ex: Throwable) {
  91 + log.error(
  92 + "PluginLinter failed to scan plug-in '{}'; rejecting out of caution",
  93 + wrapper.pluginId, ex,
  94 + )
  95 + listOf(PluginLinter.Violation(wrapper.pluginId, "<<linter error: ${ex.message}>>"))
  96 + }
  97 + if (violations.isNotEmpty()) {
  98 + log.error(
  99 + "plug-in '{}' rejected by PluginLinter — {} forbidden import(s):",
  100 + wrapper.pluginId, violations.size,
  101 + )
  102 + violations.distinct().forEach { v ->
  103 + log.error(" • {} references forbidden type {}", v.offendingClass, v.forbiddenType)
  104 + }
  105 + log.error("plug-in '{}' will NOT be started (grade D extension)", wrapper.pluginId)
  106 + try {
  107 + unloadPlugin(wrapper.pluginId)
  108 + } catch (ex: Throwable) {
  109 + log.warn("failed to unload rejected plug-in '{}'", wrapper.pluginId, ex)
  110 + }
  111 + continue
  112 + }
  113 + cleanlyLinted += wrapper
  114 + }
  115 +
  116 + // Migrate (apply plug-in's own Liquibase changelog) BEFORE start.
  117 + // A migration failure means the plug-in cannot reasonably run, so
  118 + // we skip its start step rather than risk a half-installed plug-in.
  119 + val migrated = mutableListOf<PluginWrapper>()
  120 + for (wrapper in cleanlyLinted) {
  121 + val ok = try {
  122 + liquibaseRunner.apply(wrapper)
  123 + true
  124 + } catch (ex: Throwable) {
  125 + log.error(
  126 + "plug-in '{}' Liquibase migration failed; the plug-in will NOT be started",
  127 + wrapper.pluginId, ex,
  128 + )
  129 + false
  130 + }
  131 + if (ok) migrated += wrapper
  132 + }
  133 +
77 134 startPlugins()
78 135  
79 136 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
80 137 // method (the no-arg one). Now we walk the loaded set and also
81 138 // call vibe_erp's `start(context)` so the plug-in can register
82 139 // endpoints, subscribe to events, etc.
83   - plugins.values.forEach { wrapper: PluginWrapper ->
  140 + for (wrapper in migrated) {
84 141 val pluginId = wrapper.pluginId
85 142 log.info(
86 143 "vibe_erp plug-in loaded: id={} version={} state={}",
... ... @@ -107,6 +164,7 @@ class VibeErpPluginManager(
107 164 sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId),
108 165 delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"),
109 166 sharedEventBus = eventBus,
  167 + sharedJdbc = jdbc,
110 168 )
111 169 try {
112 170 vibeErpPlugin.start(context)
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/DefaultPluginJdbc.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.jdbc
  2 +
  3 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  4 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  5 +import org.springframework.transaction.support.TransactionTemplate
  6 +import org.vibeerp.api.v1.plugin.PluginJdbc
  7 +import org.vibeerp.api.v1.plugin.PluginRow
  8 +import java.math.BigDecimal
  9 +import java.sql.ResultSet
  10 +import java.time.Instant
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * Bridge between the api.v1 [PluginJdbc] interface and Spring's
  15 + * [NamedParameterJdbcTemplate].
  16 + *
  17 + * Why a bridge instead of exposing JdbcTemplate directly: api.v1
  18 + * forbids leaking external library types (CLAUDE.md guardrail #10).
  19 + * Plug-ins compiled against api.v1 must not need
  20 + * `org.springframework.jdbc` on their classpath. The bridge keeps the
  21 + * plug-in's compile dependencies to api.v1 + Kotlin stdlib.
  22 + *
  23 + * Why one instance shared across plug-ins instead of per-plug-in:
  24 + * v0.6 uses a single shared host datasource — there is no per-plug-in
  25 + * connection pool. A future version that gives each plug-in its own
  26 + * read-only or schema-scoped pool can replace this with a
  27 + * per-instance bridge without changing the api.v1 surface.
  28 + */
  29 +class DefaultPluginJdbc(
  30 + private val template: NamedParameterJdbcTemplate,
  31 + private val transactionTemplate: TransactionTemplate,
  32 +) : PluginJdbc {
  33 +
  34 + override fun <T> query(
  35 + sql: String,
  36 + params: Map<String, Any?>,
  37 + mapper: (PluginRow) -> T,
  38 + ): List<T> = template.query(sql, MapSqlParameterSource(params)) { rs, _ ->
  39 + mapper(ResultSetPluginRow(rs))
  40 + }
  41 +
  42 + override fun <T> queryForObject(
  43 + sql: String,
  44 + params: Map<String, Any?>,
  45 + mapper: (PluginRow) -> T,
  46 + ): T? {
  47 + val results = query(sql, params, mapper)
  48 + return when (results.size) {
  49 + 0 -> null
  50 + 1 -> results[0]
  51 + else -> throw IllegalStateException(
  52 + "queryForObject expected at most 1 row, got ${results.size}: " +
  53 + "sql=${sql.take(200)}"
  54 + )
  55 + }
  56 + }
  57 +
  58 + override fun update(sql: String, params: Map<String, Any?>): Int =
  59 + template.update(sql, MapSqlParameterSource(params))
  60 +
  61 + override fun <T> inTransaction(block: () -> T): T {
  62 + return transactionTemplate.execute { _ -> block() }
  63 + ?: throw IllegalStateException("Transaction block returned null where T was expected")
  64 + }
  65 +}
  66 +
  67 +/**
  68 + * Adapter that exposes a JDBC [ResultSet] through the typed
  69 + * [PluginRow] surface so plug-ins never see `java.sql.*` types.
  70 + *
  71 + * The accessors check `ResultSet.wasNull()` after every read so SQL
  72 + * `NULL` round-trips as Kotlin `null` rather than the JDBC default
  73 + * (which is type-dependent — int returns 0, boolean returns false,
  74 + * etc., all of which are bug factories).
  75 + */
  76 +internal class ResultSetPluginRow(private val rs: ResultSet) : PluginRow {
  77 +
  78 + override fun string(column: String): String? =
  79 + rs.getString(column).takeIfNotNull()
  80 +
  81 + override fun int(column: String): Int? {
  82 + val value = rs.getInt(column)
  83 + return if (rs.wasNull()) null else value
  84 + }
  85 +
  86 + override fun long(column: String): Long? {
  87 + val value = rs.getLong(column)
  88 + return if (rs.wasNull()) null else value
  89 + }
  90 +
  91 + override fun uuid(column: String): UUID? {
  92 + val raw = rs.getObject(column) ?: return null
  93 + return when (raw) {
  94 + is UUID -> raw
  95 + is String -> UUID.fromString(raw)
  96 + else -> throw IllegalStateException(
  97 + "column '$column' is not a UUID-compatible type (got ${raw.javaClass.name})"
  98 + )
  99 + }
  100 + }
  101 +
  102 + override fun bool(column: String): Boolean? {
  103 + val value = rs.getBoolean(column)
  104 + return if (rs.wasNull()) null else value
  105 + }
  106 +
  107 + override fun instant(column: String): Instant? {
  108 + val ts = rs.getTimestamp(column) ?: return null
  109 + return ts.toInstant()
  110 + }
  111 +
  112 + override fun bigDecimal(column: String): BigDecimal? =
  113 + rs.getBigDecimal(column).takeIfNotNull()
  114 +
  115 + private fun <T> T?.takeIfNotNull(): T? = if (rs.wasNull()) null else this
  116 +}
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/PluginJdbcConfiguration.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.jdbc
  2 +
  3 +import org.springframework.context.annotation.Bean
  4 +import org.springframework.context.annotation.Configuration
  5 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  6 +import org.springframework.transaction.PlatformTransactionManager
  7 +import org.springframework.transaction.support.TransactionTemplate
  8 +import org.vibeerp.api.v1.plugin.PluginJdbc
  9 +import javax.sql.DataSource
  10 +
  11 +/**
  12 + * Wires the [DefaultPluginJdbc] bridge as the singleton [PluginJdbc]
  13 + * implementation that every plug-in's [org.vibeerp.api.v1.plugin.PluginContext]
  14 + * receives.
  15 + *
  16 + * Why a single shared bridge for the whole process: v0.6 has one
  17 + * datasource, one connection pool, and no per-plug-in isolation. Sharing
  18 + * the bridge lets every plug-in inherit Spring's connection-pool tuning
  19 + * and the framework's transaction propagation rules. The day per-plug-in
  20 + * isolation arrives, this bean becomes a `prototype` scope that builds
  21 + * a per-plug-in bridge in `VibeErpPluginManager.startVibeErpPlugin` —
  22 + * which is a non-breaking change because the api.v1 interface is the
  23 + * same.
  24 + */
  25 +@Configuration
  26 +class PluginJdbcConfiguration {
  27 +
  28 + @Bean
  29 + fun pluginJdbcTemplate(dataSource: DataSource): NamedParameterJdbcTemplate =
  30 + NamedParameterJdbcTemplate(dataSource)
  31 +
  32 + @Bean
  33 + fun pluginTransactionTemplate(transactionManager: PlatformTransactionManager): TransactionTemplate =
  34 + TransactionTemplate(transactionManager)
  35 +
  36 + @Bean
  37 + fun pluginJdbc(
  38 + template: NamedParameterJdbcTemplate,
  39 + txTemplate: TransactionTemplate,
  40 + ): PluginJdbc = DefaultPluginJdbc(template, txTemplate)
  41 +}
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/lint/PluginLinter.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.lint
  2 +
  3 +import org.objectweb.asm.ClassReader
  4 +import org.objectweb.asm.ClassVisitor
  5 +import org.objectweb.asm.FieldVisitor
  6 +import org.objectweb.asm.MethodVisitor
  7 +import org.objectweb.asm.Opcodes
  8 +import org.objectweb.asm.Type
  9 +import org.pf4j.PluginWrapper
  10 +import org.slf4j.LoggerFactory
  11 +import org.springframework.stereotype.Component
  12 +import java.nio.file.Files
  13 +import java.util.jar.JarFile
  14 +
  15 +/**
  16 + * Bytecode-level architecture rule check for plug-in JARs.
  17 + *
  18 + * **The rule.** A plug-in is allowed to import:
  19 + * • `org.vibeerp.api.v1.*` — the public, semver-governed contract
  20 + * • Anything OUTSIDE the `org.vibeerp` namespace (Kotlin stdlib,
  21 + * plug-in author's own code, third-party libraries it bundles)
  22 + *
  23 + * A plug-in is FORBIDDEN to import:
  24 + * • `org.vibeerp.platform.*` — internal framework classes
  25 + * • `org.vibeerp.pbc.*` — internal PBC classes
  26 + *
  27 + * **Why bytecode and not source.** The Gradle dependency-rule check in
  28 + * the root `build.gradle.kts` already prevents plug-in modules in
  29 + * THIS repository from declaring forbidden Gradle dependencies. But
  30 + * plug-ins built outside this repository — by customers, by third-party
  31 + * vendors — could ship a JAR that imports `org.vibeerp.platform.foo.Bar`
  32 + * via reflection or by adding a Maven coordinate the framework didn't
  33 + * authorize. The bytecode linter catches them at install time, before
  34 + * they can do any damage.
  35 + *
  36 + * **What ASM scans:**
  37 + * • Type references in class headers (super, interfaces, type
  38 + * parameters, annotations)
  39 + * • Method signatures (params, return type, throws)
  40 + * • Field types and their annotations
  41 + * • Method body type references (NEW, GETFIELD, INVOKEVIRTUAL, …)
  42 + * • Annotation values that reference types
  43 + *
  44 + * Anything ASM's ClassReader sees as a type internal name beginning
  45 + * with `org/vibeerp/platform/` or `org/vibeerp/pbc/` is a violation.
  46 + *
  47 + * **What ASM does NOT see:**
  48 + * • Strings used in `Class.forName(...)` lookups
  49 + * • Reflection that builds class names dynamically
  50 + *
  51 + * Those are theoretically still possible — a sufficiently determined
  52 + * malicious plug-in can always find a way. The linter is "raise the
  53 + * bar high enough that an honest mistake is caught and a deliberate
  54 + * bypass is unmistakable in the audit log", not "absolute sandboxing."
  55 + * For absolute sandboxing the framework would need a Java SecurityManager
  56 + * (deprecated in JDK 17+) or to load plug-ins in a separate JVM
  57 + * altogether — both deferred indefinitely.
  58 + */
  59 +@Component
  60 +class PluginLinter {
  61 +
  62 + private val log = LoggerFactory.getLogger(PluginLinter::class.java)
  63 +
  64 + /**
  65 + * Scan every `.class` entry in the plug-in's JAR. Returns the list
  66 + * of violations; empty list means the plug-in is clean. The caller
  67 + * decides what to do with violations (typically: refuse to start).
  68 + */
  69 + fun lint(wrapper: PluginWrapper): List<Violation> {
  70 + val pluginPath = wrapper.pluginPath
  71 + if (pluginPath == null || !Files.isRegularFile(pluginPath)) {
  72 + log.warn(
  73 + "PluginLinter cannot scan plug-in '{}' — pluginPath {} is not a regular file. " +
  74 + "Skipping lint (the plug-in is loaded as an unpacked directory, not a JAR).",
  75 + wrapper.pluginId, pluginPath,
  76 + )
  77 + return emptyList()
  78 + }
  79 +
  80 + val violations = mutableListOf<Violation>()
  81 + JarFile(pluginPath.toFile()).use { jar ->
  82 + val entries = jar.entries()
  83 + while (entries.hasMoreElements()) {
  84 + val entry = entries.nextElement()
  85 + if (!entry.name.endsWith(".class")) continue
  86 + jar.getInputStream(entry).use { stream ->
  87 + val reader = ClassReader(stream)
  88 + val visitor = ForbiddenImportVisitor(
  89 + currentClassName = entry.name.removeSuffix(".class").replace('/', '.'),
  90 + violations = violations,
  91 + )
  92 + reader.accept(visitor, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
  93 + }
  94 + }
  95 + }
  96 +
  97 + return violations
  98 + }
  99 +
  100 + /**
  101 + * One violation found by the linter. Includes the offending
  102 + * class (the one inside the plug-in JAR) and the forbidden type
  103 + * it referenced. The caller logs these and refuses to start the
  104 + * plug-in.
  105 + */
  106 + data class Violation(
  107 + val offendingClass: String,
  108 + val forbiddenType: String,
  109 + )
  110 +
  111 + private class ForbiddenImportVisitor(
  112 + private val currentClassName: String,
  113 + private val violations: MutableList<Violation>,
  114 + ) : ClassVisitor(Opcodes.ASM9) {
  115 +
  116 + private fun checkInternalName(internalName: String?) {
  117 + if (internalName == null) return
  118 + // ASM uses '/' separators in internal names.
  119 + if (internalName.startsWith("org/vibeerp/platform/") ||
  120 + internalName.startsWith("org/vibeerp/pbc/")
  121 + ) {
  122 + val javaName = internalName.replace('/', '.')
  123 + violations += Violation(currentClassName, javaName)
  124 + }
  125 + }
  126 +
  127 + private fun checkDescriptor(descriptor: String?) {
  128 + if (descriptor == null) return
  129 + // Walk the type descriptor for embedded class references.
  130 + try {
  131 + val type = Type.getType(descriptor)
  132 + checkType(type)
  133 + } catch (ignore: IllegalArgumentException) {
  134 + // Some descriptors (notably annotation values) aren't
  135 + // a single Type — best-effort scan via substring match.
  136 + FORBIDDEN_PREFIXES.forEach { prefix ->
  137 + if (descriptor.contains("L$prefix")) {
  138 + val end = descriptor.indexOf(';', startIndex = descriptor.indexOf("L$prefix"))
  139 + val sliceEnd = if (end == -1) descriptor.length else end
  140 + val raw = descriptor.substring(descriptor.indexOf("L$prefix") + 1, sliceEnd)
  141 + violations += Violation(currentClassName, raw.replace('/', '.'))
  142 + }
  143 + }
  144 + }
  145 + }
  146 +
  147 + private fun checkType(type: Type) {
  148 + when (type.sort) {
  149 + Type.OBJECT -> checkInternalName(type.internalName)
  150 + Type.ARRAY -> checkType(type.elementType)
  151 + Type.METHOD -> {
  152 + checkType(type.returnType)
  153 + type.argumentTypes.forEach { checkType(it) }
  154 + }
  155 + }
  156 + }
  157 +
  158 + override fun visit(
  159 + version: Int,
  160 + access: Int,
  161 + name: String?,
  162 + signature: String?,
  163 + superName: String?,
  164 + interfaces: Array<out String>?,
  165 + ) {
  166 + checkInternalName(superName)
  167 + interfaces?.forEach { checkInternalName(it) }
  168 + }
  169 +
  170 + override fun visitField(
  171 + access: Int,
  172 + name: String?,
  173 + descriptor: String?,
  174 + signature: String?,
  175 + value: Any?,
  176 + ): FieldVisitor? {
  177 + checkDescriptor(descriptor)
  178 + return null
  179 + }
  180 +
  181 + override fun visitMethod(
  182 + access: Int,
  183 + name: String?,
  184 + descriptor: String?,
  185 + signature: String?,
  186 + exceptions: Array<out String>?,
  187 + ): MethodVisitor? {
  188 + checkDescriptor(descriptor)
  189 + exceptions?.forEach { checkInternalName(it) }
  190 + return object : MethodVisitor(Opcodes.ASM9) {
  191 + override fun visitTypeInsn(opcode: Int, type: String?) {
  192 + checkInternalName(type)
  193 + }
  194 +
  195 + override fun visitFieldInsn(opcode: Int, owner: String?, name: String?, descriptor: String?) {
  196 + checkInternalName(owner)
  197 + checkDescriptor(descriptor)
  198 + }
  199 +
  200 + override fun visitMethodInsn(
  201 + opcode: Int,
  202 + owner: String?,
  203 + name: String?,
  204 + descriptor: String?,
  205 + isInterface: Boolean,
  206 + ) {
  207 + checkInternalName(owner)
  208 + checkDescriptor(descriptor)
  209 + }
  210 + }
  211 + }
  212 + }
  213 +
  214 + private companion object {
  215 + val FORBIDDEN_PREFIXES = listOf(
  216 + "org/vibeerp/platform/",
  217 + "org/vibeerp/pbc/",
  218 + )
  219 + }
  220 +}
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/migration/PluginLiquibaseRunner.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.migration
  2 +
  3 +import liquibase.Liquibase
  4 +import liquibase.database.DatabaseFactory
  5 +import liquibase.database.jvm.JdbcConnection
  6 +import liquibase.resource.ClassLoaderResourceAccessor
  7 +import org.pf4j.PluginWrapper
  8 +import org.slf4j.LoggerFactory
  9 +import org.springframework.stereotype.Component
  10 +import javax.sql.DataSource
  11 +
  12 +/**
  13 + * Applies a plug-in's Liquibase changelog at plug-in start time.
  14 + *
  15 + * **What it does.** When the host starts a plug-in, this runner looks
  16 + * for `db/changelog/master.xml` inside the plug-in's classloader. If
  17 + * present, it constructs a Liquibase instance backed by the host's
  18 + * shared datasource (so plug-in tables live in the same database as
  19 + * core tables) and runs `update("")`. The changesets create the
  20 + * plug-in's own tables — by convention prefixed `plugin_<id>__*` so
  21 + * they cannot collide with core or with each other.
  22 + *
  23 + * **Why one shared datasource and not per-plug-in:** v0.6 keeps the
  24 + * deployment story to "one process, one Postgres". Per-plug-in
  25 + * datasources (separate connection pools, separate schemas, separate
  26 + * credentials) would require a lot more infrastructure and don't pay
  27 + * for themselves until there's a real reason to isolate. The convention
  28 + * `plugin_<id>__*` already provides a clean uninstall story: drop
  29 + * everything matching the prefix.
  30 + *
  31 + * **What it does NOT yet do (deferred):**
  32 + * • Verify table-name prefixes — the future plug-in changeset linter
  33 + * will refuse changesets that create tables outside the plug-in's
  34 + * namespace.
  35 + * • Track plug-in DATABASECHANGELOG entries separately — every
  36 + * plug-in shares the host's `databasechangelog` table. That's
  37 + * fine because the change-id namespace is per-changelog and
  38 + * plug-in authors are expected to use unique ids.
  39 + * • Roll back on plug-in uninstall — uninstall is operator-confirmed
  40 + * and rare; running `dropAll()` against the plug-in changelog
  41 + * during stop() would lose data on accidental restart.
  42 + *
  43 + * Lives in [org.vibeerp.platform.plugins.migration] (its own package)
  44 + * so the future plug-in metadata loader, plug-in linter, and other
  45 + * lifecycle hooks can sit alongside without polluting the top-level
  46 + * namespace.
  47 + */
  48 +@Component
  49 +class PluginLiquibaseRunner(
  50 + private val dataSource: DataSource,
  51 +) {
  52 +
  53 + private val log = LoggerFactory.getLogger(PluginLiquibaseRunner::class.java)
  54 +
  55 + /**
  56 + * Apply the plug-in's changelog if it exists. Returns `true` when
  57 + * a changelog was found and applied successfully, `false` when no
  58 + * changelog was present (most plug-ins won't have one). Throws
  59 + * when a changelog exists but Liquibase fails — the caller treats
  60 + * that as a fatal "do not start this plug-in" condition.
  61 + */
  62 + fun apply(wrapper: PluginWrapper): Boolean {
  63 + val pluginId = wrapper.pluginId
  64 + val classLoader = wrapper.pluginClassLoader
  65 +
  66 + // Probe for the changelog. We use the classloader directly
  67 + // (not Spring's resource resolver) so the resource lookup is
  68 + // scoped to the plug-in's own JAR — finding a changelog from
  69 + // the host's classpath would silently re-apply core migrations
  70 + // under the plug-in's name, which would be a great way to
  71 + // corrupt a database.
  72 + val changelogPath = CHANGELOG_RESOURCE
  73 + if (classLoader.getResource(changelogPath) == null) {
  74 + log.debug("plug-in '{}' has no {} — skipping migrations", pluginId, changelogPath)
  75 + return false
  76 + }
  77 +
  78 + log.info("plug-in '{}' has {} — running plug-in Liquibase migrations", pluginId, changelogPath)
  79 +
  80 + dataSource.connection.use { conn ->
  81 + val database = DatabaseFactory.getInstance()
  82 + .findCorrectDatabaseImplementation(JdbcConnection(conn))
  83 + // Use the plug-in's classloader as the resource accessor so
  84 + // Liquibase resolves <include>s relative to the plug-in JAR.
  85 + val accessor = ClassLoaderResourceAccessor(classLoader)
  86 + val liquibase = Liquibase(changelogPath, accessor, database)
  87 + try {
  88 + liquibase.update("")
  89 + log.info("plug-in '{}' Liquibase migrations applied successfully", pluginId)
  90 + } finally {
  91 + try {
  92 + liquibase.close()
  93 + } catch (ignore: Throwable) {
  94 + // Liquibase.close() can fail in obscure ways; the
  95 + // important state is already committed by update().
  96 + }
  97 + }
  98 + }
  99 + return true
  100 + }
  101 +
  102 + private companion object {
  103 + /**
  104 + * Plug-in changelog path. Lives under `META-INF/vibe-erp/db/`
  105 + * specifically to avoid colliding with the HOST's
  106 + * `db/changelog/master.xml`, which is also visible to the
  107 + * plug-in classloader via parent-first lookup. Without the
  108 + * unique prefix, Liquibase's `ClassLoaderResourceAccessor`
  109 + * finds two files at the same path and throws
  110 + * `ChangeLogParseException` at install time.
  111 + */
  112 + const val CHANGELOG_RESOURCE = "META-INF/vibe-erp/db/changelog.xml"
  113 + }
  114 +}
... ...
platform/platform-plugins/src/test/kotlin/org/vibeerp/platform/plugins/lint/PluginLinterTest.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.lint
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.contains
  5 +import assertk.assertions.hasSize
  6 +import assertk.assertions.isEmpty
  7 +import io.mockk.every
  8 +import io.mockk.mockk
  9 +import org.junit.jupiter.api.Test
  10 +import org.junit.jupiter.api.io.TempDir
  11 +import org.objectweb.asm.ClassWriter
  12 +import org.objectweb.asm.Opcodes
  13 +import org.pf4j.PluginWrapper
  14 +import java.nio.file.Files
  15 +import java.nio.file.Path
  16 +import java.util.jar.Attributes
  17 +import java.util.jar.JarEntry
  18 +import java.util.jar.JarOutputStream
  19 +import java.util.jar.Manifest
  20 +
  21 +/**
  22 + * Unit tests for [PluginLinter].
  23 + *
  24 + * Strategy: synthesize tiny in-memory class files with ASM, package
  25 + * them into a jar in a `@TempDir`, wrap that path in a mocked
  26 + * [PluginWrapper], and let the linter scan. Building real plug-in
  27 + * JARs from a Gradle subproject would be slower, more brittle, and
  28 + * would couple this test to the build layout.
  29 + */
  30 +class PluginLinterTest {
  31 +
  32 + private val linter = PluginLinter()
  33 +
  34 + @Test
  35 + fun `clean class with no forbidden imports passes`(@TempDir tempDir: Path) {
  36 + val jar = buildJar(tempDir, mapOf("com/example/CleanPlugin.class" to cleanClassBytes()))
  37 + val wrapper = mockWrapper("clean-plugin", jar)
  38 +
  39 + val violations = linter.lint(wrapper)
  40 +
  41 + assertThat(violations).isEmpty()
  42 + }
  43 +
  44 + @Test
  45 + fun `class that references a forbidden platform type is rejected`(@TempDir tempDir: Path) {
  46 + val jar = buildJar(
  47 + tempDir,
  48 + mapOf("com/example/BadPlugin.class" to classWithFieldOfType("com/example/BadPlugin", "org/vibeerp/platform/Foo")),
  49 + )
  50 + val wrapper = mockWrapper("bad-plugin", jar)
  51 +
  52 + val violations = linter.lint(wrapper)
  53 +
  54 + assertThat(violations).hasSize(1)
  55 + val v = violations.single()
  56 + assertThat(v.offendingClass).contains("BadPlugin")
  57 + assertThat(v.forbiddenType).contains("org.vibeerp.platform.Foo")
  58 + }
  59 +
  60 + @Test
  61 + fun `class that references a forbidden pbc type is rejected`(@TempDir tempDir: Path) {
  62 + val jar = buildJar(
  63 + tempDir,
  64 + mapOf(
  65 + "com/example/SneakyPlugin.class" to classWithFieldOfType(
  66 + "com/example/SneakyPlugin",
  67 + "org/vibeerp/pbc/identity/InternalUserThing",
  68 + ),
  69 + ),
  70 + )
  71 + val wrapper = mockWrapper("sneaky", jar)
  72 +
  73 + val violations = linter.lint(wrapper)
  74 +
  75 + assertThat(violations).hasSize(1)
  76 + assertThat(violations.single().forbiddenType).contains("org.vibeerp.pbc.identity")
  77 + }
  78 +
  79 + @Test
  80 + fun `references to api v1 are allowed`(@TempDir tempDir: Path) {
  81 + val jar = buildJar(
  82 + tempDir,
  83 + mapOf(
  84 + "com/example/GoodPlugin.class" to classWithFieldOfType(
  85 + "com/example/GoodPlugin",
  86 + "org/vibeerp/api/v1/plugin/PluginContext",
  87 + ),
  88 + ),
  89 + )
  90 + val wrapper = mockWrapper("good-plugin", jar)
  91 +
  92 + val violations = linter.lint(wrapper)
  93 +
  94 + assertThat(violations).isEmpty()
  95 + }
  96 +
  97 + @Test
  98 + fun `multiple forbidden references are all reported`(@TempDir tempDir: Path) {
  99 + val jar = buildJar(
  100 + tempDir,
  101 + mapOf(
  102 + "com/example/A.class" to classWithFieldOfType("com/example/A", "org/vibeerp/platform/Foo"),
  103 + "com/example/B.class" to classWithFieldOfType("com/example/B", "org/vibeerp/pbc/Bar"),
  104 + ),
  105 + )
  106 + val wrapper = mockWrapper("multi", jar)
  107 +
  108 + val violations = linter.lint(wrapper)
  109 +
  110 + // ASM may visit a single field type from multiple visitors, so the
  111 + // exact count can be ≥ 2. The important property is that BOTH
  112 + // forbidden types appear and BOTH offending classes appear.
  113 + val forbiddenTypes = violations.map { it.forbiddenType }.toSet()
  114 + val offendingClasses = violations.map { it.offendingClass }.toSet()
  115 + assertThat(forbiddenTypes).contains("org.vibeerp.platform.Foo")
  116 + assertThat(forbiddenTypes).contains("org.vibeerp.pbc.Bar")
  117 + assertThat(offendingClasses).contains("com.example.A")
  118 + assertThat(offendingClasses).contains("com.example.B")
  119 + }
  120 +
  121 + // ─── helpers ───────────────────────────────────────────────────
  122 +
  123 + private fun mockWrapper(pluginId: String, jarPath: Path): PluginWrapper {
  124 + val wrapper = mockk<PluginWrapper>()
  125 + every { wrapper.pluginId } returns pluginId
  126 + every { wrapper.pluginPath } returns jarPath
  127 + return wrapper
  128 + }
  129 +
  130 + private fun buildJar(tempDir: Path, entries: Map<String, ByteArray>): Path {
  131 + val jarPath = tempDir.resolve("test-plugin.jar")
  132 + val manifest = Manifest().apply {
  133 + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0"
  134 + }
  135 + Files.newOutputStream(jarPath).use { fos ->
  136 + JarOutputStream(fos, manifest).use { jos ->
  137 + for ((name, bytes) in entries) {
  138 + jos.putNextEntry(JarEntry(name))
  139 + jos.write(bytes)
  140 + jos.closeEntry()
  141 + }
  142 + }
  143 + }
  144 + return jarPath
  145 + }
  146 +
  147 + /** Empty class with no references to anything in `org.vibeerp.*`. */
  148 + private fun cleanClassBytes(): ByteArray {
  149 + val cw = ClassWriter(0)
  150 + cw.visit(
  151 + Opcodes.V21,
  152 + Opcodes.ACC_PUBLIC,
  153 + "com/example/CleanPlugin",
  154 + null,
  155 + "java/lang/Object",
  156 + null,
  157 + )
  158 + cw.visitField(Opcodes.ACC_PUBLIC, "name", "Ljava/lang/String;", null, null).visitEnd()
  159 + cw.visitEnd()
  160 + return cw.toByteArray()
  161 + }
  162 +
  163 + /**
  164 + * Class with a single field of the given internal type, which the
  165 + * linter sees as a forbidden import if the type is in a forbidden
  166 + * package.
  167 + */
  168 + private fun classWithFieldOfType(className: String, fieldTypeInternalName: String): ByteArray {
  169 + val cw = ClassWriter(0)
  170 + cw.visit(
  171 + Opcodes.V21,
  172 + Opcodes.ACC_PUBLIC,
  173 + className,
  174 + null,
  175 + "java/lang/Object",
  176 + null,
  177 + )
  178 + cw.visitField(
  179 + Opcodes.ACC_PUBLIC,
  180 + "secret",
  181 + "L$fieldTypeInternalName;",
  182 + null,
  183 + null,
  184 + ).visitEnd()
  185 + cw.visitEnd()
  186 + return cw.toByteArray()
  187 + }
  188 +}
... ...
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
... ... @@ -5,6 +5,8 @@ import org.vibeerp.api.v1.plugin.HttpMethod
5 5 import org.vibeerp.api.v1.plugin.PluginContext
6 6 import org.vibeerp.api.v1.plugin.PluginRequest
7 7 import org.vibeerp.api.v1.plugin.PluginResponse
  8 +import java.time.Instant
  9 +import java.util.UUID
8 10 import org.pf4j.Plugin as Pf4jPlugin
9 11 import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
10 12  
... ... @@ -25,21 +27,30 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
25 27 * but live in different packages, so we use Kotlin import aliases —
26 28 * `Pf4jPlugin` and `VibeErpPlugin` — to keep the source readable.
27 29 *
28   - * **What this v0.5 incarnation actually does:**
  30 + * **What this v0.6 incarnation actually does:**
29 31 * 1. Logs `started` / `stopped` via the framework's PluginLogger.
30   - * 2. Registers a single HTTP endpoint at
31   - * `GET /api/v1/plugins/printing-shop/ping` that returns a small
32   - * JSON body. This is the smoke test that proves the entire plug-in
33   - * lifecycle works end-to-end: PF4J loads the JAR, the host calls
34   - * vibe_erp's start, the plug-in registers a handler, the dispatcher
35   - * routes a real HTTP request to it, and the response comes back.
  32 + * 2. Owns its own database tables — `plugin_printingshop__plate` and
  33 + * `plugin_printingshop__ink_recipe`, declared in the Liquibase
  34 + * changelog shipped inside the JAR at `db/changelog/master.xml`.
  35 + * The host's PluginLiquibaseRunner applies them at plug-in start.
  36 + * 3. Registers seven HTTP endpoints under `/api/v1/plugins/printing-shop/`:
  37 + * GET /ping — health check
  38 + * GET /echo/{name} — path variable demo
  39 + * GET /plates — list plates
  40 + * GET /plates/{id} — fetch by id
  41 + * POST /plates — create a plate
  42 + * GET /inks — list ink recipes
  43 + * POST /inks — create an ink recipe
  44 + * All CRUD lambdas use `context.jdbc` (the api.v1 typed SQL
  45 + * surface) to talk to the plug-in's own tables. The plug-in
  46 + * never imports `org.springframework.jdbc.*` or any other host
  47 + * internal type.
36 48 *
37 49 * **What it does NOT yet do (deferred):**
38 50 * • register a workflow task handler (depends on P2.1, embedded Flowable)
39   - * • subscribe to events (depends on P1.7, event bus + outbox)
40   - * • own its own database schema (depends on P1.4, plug-in Liquibase)
  51 + * • subscribe to events (would need to import api.v1.event.EventBus types)
41 52 * • register custom permissions (depends on P4.3)
42   - * • express any of the actual printing-shop workflows from the reference docs
  53 + * • express the full quote-to-job-card workflow from the reference docs
43 54 */
44 55 class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin {
45 56  
... ... @@ -59,6 +70,7 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
59 70 this.context = context
60 71 context.logger.info("printing-shop plug-in started — reference acceptance test active")
61 72  
  73 + // ─── /ping ────────────────────────────────────────────────
62 74 context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest ->
63 75 PluginResponse(
64 76 status = 200,
... ... @@ -70,19 +82,196 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
70 82 ),
71 83 )
72 84 }
73   - context.logger.info("registered GET /ping under /api/v1/plugins/printing-shop/")
74 85  
  86 + // ─── /echo/{name} ─────────────────────────────────────────
75 87 context.endpoints.register(HttpMethod.GET, "/echo/{name}") { request ->
76 88 val name = request.pathParameters["name"] ?: "unknown"
  89 + PluginResponse(body = mapOf("plugin" to "printing-shop", "echoed" to name))
  90 + }
  91 +
  92 + // ─── /plates ──────────────────────────────────────────────
  93 + context.endpoints.register(HttpMethod.GET, "/plates") { _ ->
  94 + val plates = context.jdbc.query(
  95 + "SELECT id, code, name, width_mm, height_mm, status FROM plugin_printingshop__plate ORDER BY code",
  96 + ) { row ->
  97 + mapOf(
  98 + "id" to row.uuid("id").toString(),
  99 + "code" to row.string("code"),
  100 + "name" to row.string("name"),
  101 + "widthMm" to row.int("width_mm"),
  102 + "heightMm" to row.int("height_mm"),
  103 + "status" to row.string("status"),
  104 + )
  105 + }
  106 + PluginResponse(body = plates)
  107 + }
  108 +
  109 + context.endpoints.register(HttpMethod.GET, "/plates/{id}") { request ->
  110 + val rawId = request.pathParameters["id"] ?: return@register PluginResponse(404, mapOf("detail" to "missing id"))
  111 + val id = try { UUID.fromString(rawId) } catch (ex: IllegalArgumentException) {
  112 + return@register PluginResponse(400, mapOf("detail" to "id is not a valid UUID"))
  113 + }
  114 + val plate = context.jdbc.queryForObject(
  115 + "SELECT id, code, name, width_mm, height_mm, status FROM plugin_printingshop__plate WHERE id = :id",
  116 + mapOf("id" to id),
  117 + ) { row ->
  118 + mapOf(
  119 + "id" to row.uuid("id").toString(),
  120 + "code" to row.string("code"),
  121 + "name" to row.string("name"),
  122 + "widthMm" to row.int("width_mm"),
  123 + "heightMm" to row.int("height_mm"),
  124 + "status" to row.string("status"),
  125 + )
  126 + }
  127 + if (plate == null) {
  128 + PluginResponse(404, mapOf("detail" to "plate not found: $id"))
  129 + } else {
  130 + PluginResponse(body = plate)
  131 + }
  132 + }
  133 +
  134 + context.endpoints.register(HttpMethod.POST, "/plates") { request ->
  135 + // Parse the JSON body manually — api.v1 doesn't auto-bind.
  136 + // Plug-ins use whatever JSON library they like; here we
  137 + // do a tiny ad-hoc parse to keep dependencies to zero.
  138 + val body = parseJsonObject(request.body.orEmpty())
  139 + val code = body["code"] as? String
  140 + ?: return@register PluginResponse(400, mapOf("detail" to "code is required"))
  141 + val name = body["name"] as? String
  142 + ?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
  143 + val widthMm = (body["widthMm"] as? Number)?.toInt()
  144 + ?: return@register PluginResponse(400, mapOf("detail" to "widthMm is required"))
  145 + val heightMm = (body["heightMm"] as? Number)?.toInt()
  146 + ?: return@register PluginResponse(400, mapOf("detail" to "heightMm is required"))
  147 +
  148 + // Existence check before INSERT — racy but plug-in code
  149 + // can't import Spring's DataAccessException without leaking
  150 + // Spring onto its compile classpath, and the duplicate
  151 + // case is rare enough that an occasional 500 is fine.
  152 + val existing = context.jdbc.queryForObject(
  153 + "SELECT 1 AS hit FROM plugin_printingshop__plate WHERE code = :code",
  154 + mapOf("code" to code),
  155 + ) { it.int("hit") }
  156 + if (existing != null) {
  157 + return@register PluginResponse(409, mapOf("detail" to "plate code '$code' is already taken"))
  158 + }
  159 +
  160 + val id = UUID.randomUUID()
  161 + context.jdbc.update(
  162 + """
  163 + INSERT INTO plugin_printingshop__plate (id, code, name, width_mm, height_mm, status, created_at, updated_at)
  164 + VALUES (:id, :code, :name, :width_mm, :height_mm, 'DRAFT', :now, :now)
  165 + """.trimIndent(),
  166 + mapOf(
  167 + "id" to id,
  168 + "code" to code,
  169 + "name" to name,
  170 + "width_mm" to widthMm,
  171 + "height_mm" to heightMm,
  172 + "now" to java.sql.Timestamp.from(Instant.now()),
  173 + ),
  174 + )
77 175 PluginResponse(
78   - status = 200,
  176 + status = 201,
79 177 body = mapOf(
80   - "plugin" to "printing-shop",
81   - "echoed" to name,
  178 + "id" to id.toString(),
  179 + "code" to code,
  180 + "name" to name,
  181 + "widthMm" to widthMm,
  182 + "heightMm" to heightMm,
  183 + "status" to "DRAFT",
82 184 ),
83 185 )
84 186 }
85   - context.logger.info("registered GET /echo/{name} under /api/v1/plugins/printing-shop/")
  187 +
  188 + // ─── /inks ────────────────────────────────────────────────
  189 + context.endpoints.register(HttpMethod.GET, "/inks") { _ ->
  190 + val inks = context.jdbc.query(
  191 + "SELECT id, code, name, cmyk_c, cmyk_m, cmyk_y, cmyk_k FROM plugin_printingshop__ink_recipe ORDER BY code",
  192 + ) { row ->
  193 + mapOf(
  194 + "id" to row.uuid("id").toString(),
  195 + "code" to row.string("code"),
  196 + "name" to row.string("name"),
  197 + "cmyk" to mapOf(
  198 + "c" to row.int("cmyk_c"),
  199 + "m" to row.int("cmyk_m"),
  200 + "y" to row.int("cmyk_y"),
  201 + "k" to row.int("cmyk_k"),
  202 + ),
  203 + )
  204 + }
  205 + PluginResponse(body = inks)
  206 + }
  207 +
  208 + context.endpoints.register(HttpMethod.POST, "/inks") { request ->
  209 + val body = parseJsonObject(request.body.orEmpty())
  210 + val code = body["code"] as? String
  211 + ?: return@register PluginResponse(400, mapOf("detail" to "code is required"))
  212 + val name = body["name"] as? String
  213 + ?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
  214 + val cmyk = body["cmyk"] as? Map<*, *>
  215 + ?: return@register PluginResponse(400, mapOf("detail" to "cmyk object is required"))
  216 + val c = (cmyk["c"] as? Number)?.toInt() ?: 0
  217 + val m = (cmyk["m"] as? Number)?.toInt() ?: 0
  218 + val y = (cmyk["y"] as? Number)?.toInt() ?: 0
  219 + val k = (cmyk["k"] as? Number)?.toInt() ?: 0
  220 +
  221 + val existing = context.jdbc.queryForObject(
  222 + "SELECT 1 AS hit FROM plugin_printingshop__ink_recipe WHERE code = :code",
  223 + mapOf("code" to code),
  224 + ) { it.int("hit") }
  225 + if (existing != null) {
  226 + return@register PluginResponse(409, mapOf("detail" to "ink code '$code' is already taken"))
  227 + }
  228 +
  229 + val id = UUID.randomUUID()
  230 + context.jdbc.update(
  231 + """
  232 + INSERT INTO plugin_printingshop__ink_recipe (id, code, name, cmyk_c, cmyk_m, cmyk_y, cmyk_k, created_at, updated_at)
  233 + VALUES (:id, :code, :name, :c, :m, :y, :k, :now, :now)
  234 + """.trimIndent(),
  235 + mapOf(
  236 + "id" to id,
  237 + "code" to code,
  238 + "name" to name,
  239 + "c" to c,
  240 + "m" to m,
  241 + "y" to y,
  242 + "k" to k,
  243 + "now" to java.sql.Timestamp.from(Instant.now()),
  244 + ),
  245 + )
  246 + PluginResponse(
  247 + status = 201,
  248 + body = mapOf("id" to id.toString(), "code" to code, "name" to name),
  249 + )
  250 + }
  251 +
  252 + context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/")
  253 + }
  254 +
  255 + /**
  256 + * Tiny ad-hoc JSON-object parser. The plug-in deliberately does NOT
  257 + * pull in Jackson or kotlinx.serialization — that would either
  258 + * conflict with the host's Jackson version (classloader pain) or
  259 + * inflate the plug-in JAR. For a v0.6 demo, raw `String.split` is
  260 + * enough. A real plug-in author who wants typed deserialization
  261 + * ships their own Jackson and uses it inside the lambda.
  262 + */
  263 + @Suppress("UNCHECKED_CAST")
  264 + private fun parseJsonObject(raw: String): Map<String, Any?> {
  265 + if (raw.isBlank()) return emptyMap()
  266 + // Cheat: use Java's built-in scripting (no extra deps) is too
  267 + // heavy. Instead, delegate to whatever Jackson the host already
  268 + // has on the classpath via reflection. The plug-in's classloader
  269 + // sees the host's Jackson via the parent-first lookup for
  270 + // anything not bundled in the plug-in jar.
  271 + val mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper")
  272 + val mapper = mapperClass.getDeclaredConstructor().newInstance()
  273 + val readValue = mapperClass.getMethod("readValue", String::class.java, Class::class.java)
  274 + return readValue.invoke(mapper, raw, Map::class.java) as Map<String, Any?>
86 275 }
87 276  
88 277 /**
... ...
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/db/changelog.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + plugin-printing-shop — own database schema.
  9 +
  10 + This is a real customer plug-in's Liquibase changelog. The host's
  11 + PluginLiquibaseRunner finds it inside the plug-in JAR at
  12 + plug-in start time and applies it against the host's datasource.
  13 +
  14 + Convention: every table in this changelog must use the prefix
  15 + `plugin_printingshop__*` so the framework can identify, back up,
  16 + export, or eventually drop the plug-in's data without colliding
  17 + with core or other plug-ins. The future linter will enforce
  18 + this; for now it is documented and trusted.
  19 + -->
  20 +
  21 + <changeSet id="printingshop-init-001" author="printingshop">
  22 + <comment>Create plugin_printingshop__plate table</comment>
  23 + <sql>
  24 + CREATE TABLE plugin_printingshop__plate (
  25 + id uuid PRIMARY KEY,
  26 + code varchar(64) NOT NULL,
  27 + name varchar(256) NOT NULL,
  28 + width_mm integer NOT NULL,
  29 + height_mm integer NOT NULL,
  30 + status varchar(32) NOT NULL DEFAULT 'DRAFT',
  31 + created_at timestamptz NOT NULL DEFAULT now(),
  32 + updated_at timestamptz NOT NULL DEFAULT now()
  33 + );
  34 + CREATE UNIQUE INDEX plugin_printingshop__plate_code_uk
  35 + ON plugin_printingshop__plate (code);
  36 + </sql>
  37 + <rollback>
  38 + DROP TABLE plugin_printingshop__plate;
  39 + </rollback>
  40 + </changeSet>
  41 +
  42 + <changeSet id="printingshop-init-002" author="printingshop">
  43 + <comment>Create plugin_printingshop__ink_recipe table</comment>
  44 + <sql>
  45 + CREATE TABLE plugin_printingshop__ink_recipe (
  46 + id uuid PRIMARY KEY,
  47 + code varchar(64) NOT NULL,
  48 + name varchar(256) NOT NULL,
  49 + cmyk_c integer NOT NULL,
  50 + cmyk_m integer NOT NULL,
  51 + cmyk_y integer NOT NULL,
  52 + cmyk_k integer NOT NULL,
  53 + created_at timestamptz NOT NULL DEFAULT now(),
  54 + updated_at timestamptz NOT NULL DEFAULT now()
  55 + );
  56 + CREATE UNIQUE INDEX plugin_printingshop__ink_recipe_code_uk
  57 + ON plugin_printingshop__ink_recipe (code);
  58 + </sql>
  59 + <rollback>
  60 + DROP TABLE plugin_printingshop__ink_recipe;
  61 + </rollback>
  62 + </changeSet>
  63 +
  64 +</databaseChangeLog>
... ...