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,6 +68,29 @@ interface PluginContext {
68 "PluginContext.endpoints is not implemented by this host. " + 68 "PluginContext.endpoints is not implemented by this host. " +
69 "Upgrade vibe_erp to v0.5 or later." 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,6 +11,19 @@
11 > filter, no Postgres Row-Level Security policies, no `TenantContext`. The 11 > filter, no Postgres Row-Level Security policies, no `TenantContext`. The
12 > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant 12 > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant
13 > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale. 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,23 +84,17 @@ The 11 architecture guardrails in `CLAUDE.md` and the 14-section design in `2026
71 84
72 These units finish the platform layer so PBCs can be implemented without inventing scaffolding. 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 **Module:** `platform-plugins` 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 ### P1.5 — Metadata store seeding 99 ### P1.5 — Metadata store seeding
93 **Module:** new `platform-metadata` module (or expand `platform-persistence`) 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,11 +108,9 @@ These units finish the platform layer so PBCs can be implemented without inventi
101 **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. 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 **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. 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 **Module:** new `platform-events` module 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 ### P1.8 — JasperReports integration 115 ### P1.8 — JasperReports integration
111 **Module:** new `platform-reporting` module 116 **Module:** new `platform-reporting` module
@@ -196,15 +201,9 @@ These units finish the platform layer so PBCs can be implemented without inventi @@ -196,15 +201,9 @@ These units finish the platform layer so PBCs can be implemented without inventi
196 > the only security. Until P4.1 lands, vibe_erp is only safe to run on 201 > the only security. Until P4.1 lands, vibe_erp is only safe to run on
197 > `localhost`. 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 ### P4.2 — OIDC integration 208 ### P4.2 — OIDC integration
210 **Module:** `pbc-identity` 209 **Module:** `pbc-identity`
@@ -236,8 +235,8 @@ Each PBC follows the same 7-step recipe: @@ -236,8 +235,8 @@ Each PBC follows the same 7-step recipe:
236 > instance — everything in the database belongs to the one company that 235 > instance — everything in the database belongs to the one company that
237 > owns the instance. 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 ### P5.2 — `pbc-partners` — customers, suppliers, contacts 241 ### P5.2 — `pbc-partners` — customers, suppliers, contacts
243 Companies, addresses, contacts, contact channels. PII-tagged from day one (CLAUDE.md guardrail #6 / DSAR). 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,7 +351,9 @@ REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3
352 ``` 351 ```
353 352
354 Sensible ordering for one developer (single-tenant world): 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 Sensible parallel ordering for a team of three: 358 Sensible parallel ordering for a team of three:
358 - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1 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,6 +34,9 @@ kotlin-reflect = { module = &quot;org.jetbrains.kotlin:kotlin-reflect&quot;, version.ref =
34 jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } 34 jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
35 jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } 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 # Validation 40 # Validation
38 jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api", version.ref = "jakartaValidation" } 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,12 +28,16 @@ dependencies {
28 28
29 implementation(libs.spring.boot.starter) 29 implementation(libs.spring.boot.starter)
30 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher 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 implementation(libs.pf4j) 34 implementation(libs.pf4j)
32 implementation(libs.pf4j.spring) 35 implementation(libs.pf4j.spring)
33 36
34 testImplementation(libs.spring.boot.starter.test) 37 testImplementation(libs.spring.boot.starter.test)
35 testImplementation(libs.junit.jupiter) 38 testImplementation(libs.junit.jupiter)
36 testImplementation(libs.assertk) 39 testImplementation(libs.assertk)
  40 + testImplementation(libs.mockk)
37 } 41 }
38 42
39 tasks.test { 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,6 +8,7 @@ import org.vibeerp.api.v1.i18n.Translator
8 import org.vibeerp.api.v1.persistence.Transaction 8 import org.vibeerp.api.v1.persistence.Transaction
9 import org.vibeerp.api.v1.plugin.PluginContext 9 import org.vibeerp.api.v1.plugin.PluginContext
10 import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar 10 import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
  11 +import org.vibeerp.api.v1.plugin.PluginJdbc
11 import org.vibeerp.api.v1.plugin.PluginLogger 12 import org.vibeerp.api.v1.plugin.PluginLogger
12 import org.vibeerp.api.v1.security.PermissionCheck 13 import org.vibeerp.api.v1.security.PermissionCheck
13 14
@@ -38,6 +39,7 @@ internal class DefaultPluginContext( @@ -38,6 +39,7 @@ internal class DefaultPluginContext(
38 sharedRegistrar: PluginEndpointRegistrar, 39 sharedRegistrar: PluginEndpointRegistrar,
39 delegateLogger: Logger, 40 delegateLogger: Logger,
40 private val sharedEventBus: EventBus, 41 private val sharedEventBus: EventBus,
  42 + private val sharedJdbc: PluginJdbc,
41 ) : PluginContext { 43 ) : PluginContext {
42 44
43 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) 45 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
@@ -54,6 +56,20 @@ internal class DefaultPluginContext( @@ -54,6 +56,20 @@ internal class DefaultPluginContext(
54 */ 56 */
55 override val eventBus: EventBus = sharedEventBus 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 // ─── Not yet implemented ─────────────────────────────────────── 73 // ─── Not yet implemented ───────────────────────────────────────
58 74
59 override val transaction: Transaction 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 +8,11 @@ import org.springframework.beans.factory.InitializingBean
8 import org.springframework.boot.context.properties.ConfigurationProperties 8 import org.springframework.boot.context.properties.ConfigurationProperties
9 import org.springframework.stereotype.Component 9 import org.springframework.stereotype.Component
10 import org.vibeerp.api.v1.event.EventBus 10 import org.vibeerp.api.v1.event.EventBus
  11 +import org.vibeerp.api.v1.plugin.PluginJdbc
11 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry 12 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
12 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar 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 import java.nio.file.Files 16 import java.nio.file.Files
14 import java.nio.file.Path 17 import java.nio.file.Path
15 import java.nio.file.Paths 18 import java.nio.file.Paths
@@ -21,12 +24,19 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin @@ -21,12 +24,19 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
21 * Wired as a Spring bean so its lifecycle follows the Spring application context: 24 * Wired as a Spring bean so its lifecycle follows the Spring application context:
22 * • on startup, scans the configured plug-ins directory, loads every JAR 25 * • on startup, scans the configured plug-ins directory, loads every JAR
23 * that passes manifest validation and the API compatibility check, 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 * starts each plug-in via PF4J, then walks the loaded plug-ins and calls 29 * starts each plug-in via PF4J, then walks the loaded plug-ins and calls
25 * `vibe_erp.api.v1.plugin.Plugin.start(context)` on each one with a real 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 * • on shutdown, stops every plug-in cleanly so they get a chance to release 32 * • on shutdown, stops every plug-in cleanly so they get a chance to release
28 * resources, and removes their endpoint registrations from the registry 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 * **Classloader contract.** PF4J's default `PluginClassLoader` is 40 * **Classloader contract.** PF4J's default `PluginClassLoader` is
31 * child-first: it tries the plug-in's own jar first and falls back to 41 * child-first: it tries the plug-in's own jar first and falls back to
32 * the parent (host) classloader on miss. That works for api.v1 as long 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,13 +46,6 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
36 * the documented build setup (`implementation(project(":api:api-v1"))` 46 * the documented build setup (`implementation(project(":api:api-v1"))`
37 * for compile-time, host classpath at runtime). 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 * Reference: architecture spec section 7 ("Plug-in lifecycle"). 49 * Reference: architecture spec section 7 ("Plug-in lifecycle").
47 */ 50 */
48 @Component 51 @Component
@@ -50,6 +53,9 @@ class VibeErpPluginManager( @@ -50,6 +53,9 @@ class VibeErpPluginManager(
50 private val properties: VibeErpPluginsProperties, 53 private val properties: VibeErpPluginsProperties,
51 private val endpointRegistry: PluginEndpointRegistry, 54 private val endpointRegistry: PluginEndpointRegistry,
52 private val eventBus: EventBus, 55 private val eventBus: EventBus,
  56 + private val jdbc: PluginJdbc,
  57 + private val linter: PluginLinter,
  58 + private val liquibaseRunner: PluginLiquibaseRunner,
53 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { 59 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
54 60
55 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) 61 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
@@ -74,13 +80,64 @@ class VibeErpPluginManager( @@ -74,13 +80,64 @@ class VibeErpPluginManager(
74 } 80 }
75 log.info("vibe_erp scanning plug-ins from {}", dir) 81 log.info("vibe_erp scanning plug-ins from {}", dir)
76 loadPlugins() 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 startPlugins() 134 startPlugins()
78 135
79 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` 136 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
80 // method (the no-arg one). Now we walk the loaded set and also 137 // method (the no-arg one). Now we walk the loaded set and also
81 // call vibe_erp's `start(context)` so the plug-in can register 138 // call vibe_erp's `start(context)` so the plug-in can register
82 // endpoints, subscribe to events, etc. 139 // endpoints, subscribe to events, etc.
83 - plugins.values.forEach { wrapper: PluginWrapper -> 140 + for (wrapper in migrated) {
84 val pluginId = wrapper.pluginId 141 val pluginId = wrapper.pluginId
85 log.info( 142 log.info(
86 "vibe_erp plug-in loaded: id={} version={} state={}", 143 "vibe_erp plug-in loaded: id={} version={} state={}",
@@ -107,6 +164,7 @@ class VibeErpPluginManager( @@ -107,6 +164,7 @@ class VibeErpPluginManager(
107 sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId), 164 sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId),
108 delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"), 165 delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"),
109 sharedEventBus = eventBus, 166 sharedEventBus = eventBus,
  167 + sharedJdbc = jdbc,
110 ) 168 )
111 try { 169 try {
112 vibeErpPlugin.start(context) 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,6 +5,8 @@ import org.vibeerp.api.v1.plugin.HttpMethod
5 import org.vibeerp.api.v1.plugin.PluginContext 5 import org.vibeerp.api.v1.plugin.PluginContext
6 import org.vibeerp.api.v1.plugin.PluginRequest 6 import org.vibeerp.api.v1.plugin.PluginRequest
7 import org.vibeerp.api.v1.plugin.PluginResponse 7 import org.vibeerp.api.v1.plugin.PluginResponse
  8 +import java.time.Instant
  9 +import java.util.UUID
8 import org.pf4j.Plugin as Pf4jPlugin 10 import org.pf4j.Plugin as Pf4jPlugin
9 import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin 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,21 +27,30 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
25 * but live in different packages, so we use Kotlin import aliases — 27 * but live in different packages, so we use Kotlin import aliases —
26 * `Pf4jPlugin` and `VibeErpPlugin` — to keep the source readable. 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 * 1. Logs `started` / `stopped` via the framework's PluginLogger. 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 * **What it does NOT yet do (deferred):** 49 * **What it does NOT yet do (deferred):**
38 * • register a workflow task handler (depends on P2.1, embedded Flowable) 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 * • register custom permissions (depends on P4.3) 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 class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin { 55 class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin {
45 56
@@ -59,6 +70,7 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP @@ -59,6 +70,7 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
59 this.context = context 70 this.context = context
60 context.logger.info("printing-shop plug-in started — reference acceptance test active") 71 context.logger.info("printing-shop plug-in started — reference acceptance test active")
61 72
  73 + // ─── /ping ────────────────────────────────────────────────
62 context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest -> 74 context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest ->
63 PluginResponse( 75 PluginResponse(
64 status = 200, 76 status = 200,
@@ -70,19 +82,196 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP @@ -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 context.endpoints.register(HttpMethod.GET, "/echo/{name}") { request -> 87 context.endpoints.register(HttpMethod.GET, "/echo/{name}") { request ->
76 val name = request.pathParameters["name"] ?: "unknown" 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 PluginResponse( 175 PluginResponse(
78 - status = 200, 176 + status = 201,
79 body = mapOf( 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>