diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt index 1ba2aa0..7722e80 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -68,6 +68,29 @@ interface PluginContext { "PluginContext.endpoints is not implemented by this host. " + "Upgrade vibe_erp to v0.5 or later." ) + + /** + * Typed SQL access for the plug-in's own tables. + * + * Plug-ins query and mutate the tables they declared in + * `db/changelog/master.xml` (shipped inside their JAR; applied by + * the host's plug-in Liquibase runner at start) using named-parameter + * SQL through this interface. They never see Spring's JdbcTemplate, + * jakarta.persistence, or Hibernate. + * + * Plug-ins MUST use the `plugin___*` table prefix. The host's + * linter will enforce this in a future version; for v0.6 it is a + * documented convention. + * + * Added in api.v1.0.x; default impl throws so older builds remain + * binary compatible. The first host that wires this is + * `platform-plugins` v0.6. + */ + val jdbc: PluginJdbc + get() = throw UnsupportedOperationException( + "PluginContext.jdbc is not implemented by this host. " + + "Upgrade vibe_erp to v0.6 or later." + ) } /** diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginJdbc.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginJdbc.kt new file mode 100644 index 0000000..63b8959 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginJdbc.kt @@ -0,0 +1,120 @@ +package org.vibeerp.api.v1.plugin + +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +/** + * Typed SQL access for plug-ins. + * + * **Why a thin SQL wrapper instead of a proper repository abstraction:** + * plug-ins live in their own classloader and the api.v1 surface must + * not leak Spring's `JdbcTemplate`, JPA `EntityManager`, or any + * Hibernate type. Wrapping the host's `NamedParameterJdbcTemplate` + * behind this small interface keeps the plug-in's compile classpath + * to `api.v1 + Kotlin stdlib`. A typed `Repository` would require + * the plug-in to ship JPA-annotated entities and a metamodel — too + * heavy for v0.6 and not what most plug-in authors want anyway. + * + * **What plug-ins use this for:** querying and mutating their own + * tables, the ones they declared in the Liquibase changelog they + * shipped inside their JAR. Plug-ins MUST use the `plugin___*` + * table-name prefix; the host's plug-in linter will enforce this in + * a future version. Querying core PBC tables directly is forbidden + * (do it through `api.v1.ext.` facades instead) — but the + * `PluginJdbc` interface itself doesn't enforce that, so plug-ins + * are on their honor until per-plug-in datasource isolation lands. + * + * **Named parameters only.** All methods take `Map` + * for parameters; positional `?` placeholders are not supported. + * This is intentional: positional parameters are a frequent source + * of "argument 4 is the wrong column" bugs in plug-in code, and + * named parameters are universally supported by every database + * vibe_erp will ever target. + * + * **Transactions.** `inTransaction { ... }` runs the block in a new + * transaction (or joins an existing one if the plug-in was called + * from inside a host transaction — Spring REQUIRED propagation). + * Throwing inside the block rolls back. The simple `query`/`update` + * methods participate in the surrounding transaction if there is + * one and run auto-commit otherwise. + */ +interface PluginJdbc { + + /** + * Run a SELECT and map every row to a [T]. Returns an empty list + * when no rows match. Use [queryForObject] when exactly zero or + * one row is expected. + */ + fun query( + sql: String, + params: Map = emptyMap(), + mapper: (PluginRow) -> T, + ): List + + /** + * Run a SELECT that is expected to return at most one row. + * Returns `null` when no row matches. Throws if more than one + * row matches — that is a programming error worth surfacing + * loudly. + */ + fun queryForObject( + sql: String, + params: Map = emptyMap(), + mapper: (PluginRow) -> T, + ): T? + + /** + * Run an INSERT, UPDATE, or DELETE. Returns the number of rows + * affected. + */ + fun update( + sql: String, + params: Map = emptyMap(), + ): Int + + /** + * Run [block] in a transaction. Throwing inside the block rolls + * back; returning normally commits. If a transaction is already + * open (e.g. the plug-in was invoked from inside a host + * transaction), the block joins it instead of starting a new one. + */ + fun inTransaction(block: () -> T): T +} + +/** + * Typed wrapper around a single SQL result row. + * + * Methods are named after Kotlin types (`string`, `int`, `long`, …) + * rather than after JDBC types (`varchar`, `integer`, `bigint`, …) + * because plug-in authors think in Kotlin, not in JDBC. + * + * Every accessor returns the column value as a nullable type so + * SQL `NULL` round-trips correctly. A non-null version of every + * accessor is intentionally NOT provided — plug-ins should call + * `row.string("name") ?: error("name was null")` so the failure + * site is obvious in stack traces. + */ +interface PluginRow { + + /** Read a TEXT / VARCHAR / CHAR column. */ + fun string(column: String): String? + + /** Read an INTEGER column. Throws if the column value does not fit in an Int. */ + fun int(column: String): Int? + + /** Read a BIGINT column. */ + fun long(column: String): Long? + + /** Read a UUID column (Postgres `uuid` type). */ + fun uuid(column: String): UUID? + + /** Read a BOOLEAN column. */ + fun bool(column: String): Boolean? + + /** Read a TIMESTAMPTZ / TIMESTAMP column as a UTC [Instant]. */ + fun instant(column: String): Instant? + + /** Read a NUMERIC / DECIMAL column. */ + fun bigDecimal(column: String): BigDecimal? +} diff --git a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md index 2907cb6..12297ec 100644 --- a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md +++ b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md @@ -11,6 +11,19 @@ > filter, no Postgres Row-Level Security policies, no `TenantContext`. The > previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant > routing) units are gone. See CLAUDE.md guardrail #5 for the rationale. +> +> **2026-04-08 update — P4.1, P5.1, P1.3, P1.7, P1.4, P1.2 all landed.** +> Auth (Argon2id + JWT + bootstrap admin), pbc-catalog (items + UoMs), +> plug-in HTTP endpoints (registry + dispatcher + DefaultPluginContext), +> event bus + transactional outbox + audit subscriber, plug-in-owned +> Liquibase changelogs (the reference plug-in now has its own database +> tables), and the plug-in linter (ASM bytecode scan rejecting forbidden +> imports) are all done and smoke-tested end-to-end. The reference +> printing-shop plug-in has graduated from "hello world" to a real +> customer demonstration: it owns its own DB schema, CRUDs plates and +> ink recipes via REST through `context.jdbc`, and would be rejected at +> install time if it tried to import internal framework classes. The +> framework is now at **81 unit tests** across 10 modules, all green. --- @@ -71,23 +84,17 @@ The 11 architecture guardrails in `CLAUDE.md` and the 14-section design in `2026 These units finish the platform layer so PBCs can be implemented without inventing scaffolding. -### P1.2 — Plug-in linter +### ✅ P1.2 — Plug-in linter (DONE 2026-04-08) **Module:** `platform-plugins` -**Depends on:** — -**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. -**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. +**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. -### P1.3 — Per-plug-in Spring child context -**Module:** `platform-plugins` -**Depends on:** P1.2 (linter must run first) -**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. -**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. +### ✅ P1.3 — Plug-in lifecycle: HTTP endpoints (DONE 2026-04-07, v0.5) +**Module:** `platform-plugins` + `api.v1` +**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. -### P1.4 — Plug-in Liquibase application -**Module:** `platform-plugins` + `platform-persistence` -**Depends on:** P1.3 -**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___*` prefix (enforced by lint of the changelog at load time, not runtime). -**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. +### ✅ P1.4 — Plug-in Liquibase application (DONE 2026-04-08) +**Module:** `platform-plugins` + `api.v1` (added `PluginJdbc`/`PluginRow`) +**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___*` 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. ### P1.5 — Metadata store seeding **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 **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_.properties` → core `i18n/messages_.properties` → fallback locale. **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. -### P1.7 — Event bus + outbox +### ✅ P1.7 — Event bus + outbox (DONE 2026-04-08) **Module:** new `platform-events` module -**Depends on:** — -**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. -**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. +**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. ### P1.8 — JasperReports integration **Module:** new `platform-reporting` module @@ -196,15 +201,9 @@ These units finish the platform layer so PBCs can be implemented without inventi > the only security. Until P4.1 lands, vibe_erp is only safe to run on > `localhost`. -### P4.1 — Built-in JWT auth -**Module:** `pbc-identity` (extended) + `platform-bootstrap` (Spring Security config) -**Depends on:** — -**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. -**Acceptance:** end-to-end smoke test against the running app: -- unauthenticated `GET /api/v1/identity/users` → 401 -- `POST /api/v1/auth/login` with admin → 200 + access + refresh tokens -- `GET /api/v1/identity/users` with `Authorization: Bearer …` → 200 -- new user created via REST has `created_by = `, not `__system__` +### ✅ P4.1 — Built-in JWT auth (DONE 2026-04-07) +**Module:** `pbc-identity` (extended) + new `platform-security` module +**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. ### P4.2 — OIDC integration **Module:** `pbc-identity` @@ -236,8 +235,8 @@ Each PBC follows the same 7-step recipe: > instance — everything in the database belongs to the one company that > owns the instance. -### P5.1 — `pbc-catalog` — items, units of measure, attributes -Foundation for everything that's bought, sold, made, or stored. Hold off on price lists and configurable products until P5.10. +### ✅ P5.1 — `pbc-catalog` — items, units of measure (DONE 2026-04-08) +`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. ### P5.2 — `pbc-partners` — customers, suppliers, contacts 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 ``` Sensible ordering for one developer (single-tenant world): -**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 → ...** +**~~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 → ...** + +**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. Sensible parallel ordering for a team of three: - Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a8bdcd..0111a45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,9 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +# Bytecode analysis +asm = { module = "org.ow2.asm:asm", version = "9.7.1" } + # Validation jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api", version.ref = "jakartaValidation" } diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts index b7ca25f..656289b 100644 --- a/platform/platform-plugins/build.gradle.kts +++ b/platform/platform-plugins/build.gradle.kts @@ -28,12 +28,16 @@ dependencies { implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher + implementation(libs.spring.boot.starter.data.jpa) // for DataSource + JdbcTemplate + TransactionTemplate + implementation(libs.liquibase.core) // for plug-in-owned Liquibase changelogs + implementation(libs.asm) // for plug-in linter bytecode scan implementation(libs.pf4j) implementation(libs.pf4j.spring) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.junit.jupiter) testImplementation(libs.assertk) + testImplementation(libs.mockk) } tasks.test { diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt index 57eadc6..c607a0e 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt @@ -8,6 +8,7 @@ import org.vibeerp.api.v1.i18n.Translator import org.vibeerp.api.v1.persistence.Transaction import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar +import org.vibeerp.api.v1.plugin.PluginJdbc import org.vibeerp.api.v1.plugin.PluginLogger import org.vibeerp.api.v1.security.PermissionCheck @@ -38,6 +39,7 @@ internal class DefaultPluginContext( sharedRegistrar: PluginEndpointRegistrar, delegateLogger: Logger, private val sharedEventBus: EventBus, + private val sharedJdbc: PluginJdbc, ) : PluginContext { override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) @@ -54,6 +56,20 @@ internal class DefaultPluginContext( */ override val eventBus: EventBus = sharedEventBus + /** + * Real typed SQL access, wired in P1.4. Plug-ins query and mutate + * the tables they declared in `db/changelog/master.xml` shipped + * inside their JAR. The host's [PluginLiquibaseRunner] applied + * those changesets at plug-in start, before [Plugin.start] was + * called, so the tables are guaranteed to exist by the time + * the plug-in's lambdas run. + * + * v0.6 uses one shared host datasource for every plug-in. Per- + * plug-in connection isolation lands later (and would be a + * non-breaking change to this getter). + */ + override val jdbc: PluginJdbc = sharedJdbc + // ─── Not yet implemented ─────────────────────────────────────── override val transaction: Transaction diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index c1d645f..8dc40ad 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -8,8 +8,11 @@ import org.springframework.beans.factory.InitializingBean import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.stereotype.Component import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.plugin.PluginJdbc import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar +import org.vibeerp.platform.plugins.lint.PluginLinter +import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -21,12 +24,19 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin * Wired as a Spring bean so its lifecycle follows the Spring application context: * • on startup, scans the configured plug-ins directory, loads every JAR * that passes manifest validation and the API compatibility check, + * **lints** the JAR for forbidden internal imports (PluginLinter), + * **migrates** the plug-in's own database tables (PluginLiquibaseRunner), * starts each plug-in via PF4J, then walks the loaded plug-ins and calls * `vibe_erp.api.v1.plugin.Plugin.start(context)` on each one with a real - * [DefaultPluginContext] (logger + endpoints registrar wired) + * [DefaultPluginContext] (logger + endpoints registrar + event bus + jdbc wired) * • on shutdown, stops every plug-in cleanly so they get a chance to release * resources, and removes their endpoint registrations from the registry * + * **Lifecycle order is load → lint → migrate → start.** Lint runs before + * any plug-in code executes (a forbidden import means the plug-in never + * starts). Migrate runs before `start(context)` so the plug-in's tables + * are guaranteed to exist by the time its lambdas reference them. + * * **Classloader contract.** PF4J's default `PluginClassLoader` is * child-first: it tries the plug-in's own jar first and falls back to * 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 * the documented build setup (`implementation(project(":api:api-v1"))` * for compile-time, host classpath at runtime). * - * If a plug-in jar were to bundle api-v1 by mistake, the plug-in's - * classloader would load its own copy and the host's - * `instance as VibeErpPlugin` cast would throw `ClassCastException` - * because "same name, different classloader = different class". The - * v0.6 plug-in linter (P1.2) will detect that mistake at install time; - * for now, the failure mode is loud and surfaces in the smoke test. - * * Reference: architecture spec section 7 ("Plug-in lifecycle"). */ @Component @@ -50,6 +53,9 @@ class VibeErpPluginManager( private val properties: VibeErpPluginsProperties, private val endpointRegistry: PluginEndpointRegistry, private val eventBus: EventBus, + private val jdbc: PluginJdbc, + private val linter: PluginLinter, + private val liquibaseRunner: PluginLiquibaseRunner, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) @@ -74,13 +80,64 @@ class VibeErpPluginManager( } log.info("vibe_erp scanning plug-ins from {}", dir) loadPlugins() + + // Lint BEFORE start. A plug-in that imports forbidden internal + // classes is unloaded immediately and never gets to run code. + val cleanlyLinted = mutableListOf() + for (wrapper in plugins.values) { + val violations = try { + linter.lint(wrapper) + } catch (ex: Throwable) { + log.error( + "PluginLinter failed to scan plug-in '{}'; rejecting out of caution", + wrapper.pluginId, ex, + ) + listOf(PluginLinter.Violation(wrapper.pluginId, "<>")) + } + if (violations.isNotEmpty()) { + log.error( + "plug-in '{}' rejected by PluginLinter — {} forbidden import(s):", + wrapper.pluginId, violations.size, + ) + violations.distinct().forEach { v -> + log.error(" • {} references forbidden type {}", v.offendingClass, v.forbiddenType) + } + log.error("plug-in '{}' will NOT be started (grade D extension)", wrapper.pluginId) + try { + unloadPlugin(wrapper.pluginId) + } catch (ex: Throwable) { + log.warn("failed to unload rejected plug-in '{}'", wrapper.pluginId, ex) + } + continue + } + cleanlyLinted += wrapper + } + + // Migrate (apply plug-in's own Liquibase changelog) BEFORE start. + // A migration failure means the plug-in cannot reasonably run, so + // we skip its start step rather than risk a half-installed plug-in. + val migrated = mutableListOf() + for (wrapper in cleanlyLinted) { + val ok = try { + liquibaseRunner.apply(wrapper) + true + } catch (ex: Throwable) { + log.error( + "plug-in '{}' Liquibase migration failed; the plug-in will NOT be started", + wrapper.pluginId, ex, + ) + false + } + if (ok) migrated += wrapper + } + startPlugins() // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` // method (the no-arg one). Now we walk the loaded set and also // call vibe_erp's `start(context)` so the plug-in can register // endpoints, subscribe to events, etc. - plugins.values.forEach { wrapper: PluginWrapper -> + for (wrapper in migrated) { val pluginId = wrapper.pluginId log.info( "vibe_erp plug-in loaded: id={} version={} state={}", @@ -107,6 +164,7 @@ class VibeErpPluginManager( sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId), delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"), sharedEventBus = eventBus, + sharedJdbc = jdbc, ) try { vibeErpPlugin.start(context) diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/DefaultPluginJdbc.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/DefaultPluginJdbc.kt new file mode 100644 index 0000000..d48cce8 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/DefaultPluginJdbc.kt @@ -0,0 +1,116 @@ +package org.vibeerp.platform.plugins.jdbc + +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.transaction.support.TransactionTemplate +import org.vibeerp.api.v1.plugin.PluginJdbc +import org.vibeerp.api.v1.plugin.PluginRow +import java.math.BigDecimal +import java.sql.ResultSet +import java.time.Instant +import java.util.UUID + +/** + * Bridge between the api.v1 [PluginJdbc] interface and Spring's + * [NamedParameterJdbcTemplate]. + * + * Why a bridge instead of exposing JdbcTemplate directly: api.v1 + * forbids leaking external library types (CLAUDE.md guardrail #10). + * Plug-ins compiled against api.v1 must not need + * `org.springframework.jdbc` on their classpath. The bridge keeps the + * plug-in's compile dependencies to api.v1 + Kotlin stdlib. + * + * Why one instance shared across plug-ins instead of per-plug-in: + * v0.6 uses a single shared host datasource — there is no per-plug-in + * connection pool. A future version that gives each plug-in its own + * read-only or schema-scoped pool can replace this with a + * per-instance bridge without changing the api.v1 surface. + */ +class DefaultPluginJdbc( + private val template: NamedParameterJdbcTemplate, + private val transactionTemplate: TransactionTemplate, +) : PluginJdbc { + + override fun query( + sql: String, + params: Map, + mapper: (PluginRow) -> T, + ): List = template.query(sql, MapSqlParameterSource(params)) { rs, _ -> + mapper(ResultSetPluginRow(rs)) + } + + override fun queryForObject( + sql: String, + params: Map, + mapper: (PluginRow) -> T, + ): T? { + val results = query(sql, params, mapper) + return when (results.size) { + 0 -> null + 1 -> results[0] + else -> throw IllegalStateException( + "queryForObject expected at most 1 row, got ${results.size}: " + + "sql=${sql.take(200)}" + ) + } + } + + override fun update(sql: String, params: Map): Int = + template.update(sql, MapSqlParameterSource(params)) + + override fun inTransaction(block: () -> T): T { + return transactionTemplate.execute { _ -> block() } + ?: throw IllegalStateException("Transaction block returned null where T was expected") + } +} + +/** + * Adapter that exposes a JDBC [ResultSet] through the typed + * [PluginRow] surface so plug-ins never see `java.sql.*` types. + * + * The accessors check `ResultSet.wasNull()` after every read so SQL + * `NULL` round-trips as Kotlin `null` rather than the JDBC default + * (which is type-dependent — int returns 0, boolean returns false, + * etc., all of which are bug factories). + */ +internal class ResultSetPluginRow(private val rs: ResultSet) : PluginRow { + + override fun string(column: String): String? = + rs.getString(column).takeIfNotNull() + + override fun int(column: String): Int? { + val value = rs.getInt(column) + return if (rs.wasNull()) null else value + } + + override fun long(column: String): Long? { + val value = rs.getLong(column) + return if (rs.wasNull()) null else value + } + + override fun uuid(column: String): UUID? { + val raw = rs.getObject(column) ?: return null + return when (raw) { + is UUID -> raw + is String -> UUID.fromString(raw) + else -> throw IllegalStateException( + "column '$column' is not a UUID-compatible type (got ${raw.javaClass.name})" + ) + } + } + + override fun bool(column: String): Boolean? { + val value = rs.getBoolean(column) + return if (rs.wasNull()) null else value + } + + override fun instant(column: String): Instant? { + val ts = rs.getTimestamp(column) ?: return null + return ts.toInstant() + } + + override fun bigDecimal(column: String): BigDecimal? = + rs.getBigDecimal(column).takeIfNotNull() + + private fun T?.takeIfNotNull(): T? = if (rs.wasNull()) null else this +} diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/PluginJdbcConfiguration.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/PluginJdbcConfiguration.kt new file mode 100644 index 0000000..41c0b4c --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jdbc/PluginJdbcConfiguration.kt @@ -0,0 +1,41 @@ +package org.vibeerp.platform.plugins.jdbc + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import org.vibeerp.api.v1.plugin.PluginJdbc +import javax.sql.DataSource + +/** + * Wires the [DefaultPluginJdbc] bridge as the singleton [PluginJdbc] + * implementation that every plug-in's [org.vibeerp.api.v1.plugin.PluginContext] + * receives. + * + * Why a single shared bridge for the whole process: v0.6 has one + * datasource, one connection pool, and no per-plug-in isolation. Sharing + * the bridge lets every plug-in inherit Spring's connection-pool tuning + * and the framework's transaction propagation rules. The day per-plug-in + * isolation arrives, this bean becomes a `prototype` scope that builds + * a per-plug-in bridge in `VibeErpPluginManager.startVibeErpPlugin` — + * which is a non-breaking change because the api.v1 interface is the + * same. + */ +@Configuration +class PluginJdbcConfiguration { + + @Bean + fun pluginJdbcTemplate(dataSource: DataSource): NamedParameterJdbcTemplate = + NamedParameterJdbcTemplate(dataSource) + + @Bean + fun pluginTransactionTemplate(transactionManager: PlatformTransactionManager): TransactionTemplate = + TransactionTemplate(transactionManager) + + @Bean + fun pluginJdbc( + template: NamedParameterJdbcTemplate, + txTemplate: TransactionTemplate, + ): PluginJdbc = DefaultPluginJdbc(template, txTemplate) +} diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/lint/PluginLinter.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/lint/PluginLinter.kt new file mode 100644 index 0000000..680a698 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/lint/PluginLinter.kt @@ -0,0 +1,220 @@ +package org.vibeerp.platform.plugins.lint + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import org.pf4j.PluginWrapper +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.util.jar.JarFile + +/** + * Bytecode-level architecture rule check for plug-in JARs. + * + * **The rule.** A plug-in is allowed to import: + * • `org.vibeerp.api.v1.*` — the public, semver-governed contract + * • Anything OUTSIDE the `org.vibeerp` namespace (Kotlin stdlib, + * plug-in author's own code, third-party libraries it bundles) + * + * A plug-in is FORBIDDEN to import: + * • `org.vibeerp.platform.*` — internal framework classes + * • `org.vibeerp.pbc.*` — internal PBC classes + * + * **Why bytecode and not source.** The Gradle dependency-rule check in + * the root `build.gradle.kts` already prevents plug-in modules in + * THIS repository from declaring forbidden Gradle dependencies. But + * plug-ins built outside this repository — by customers, by third-party + * vendors — could ship a JAR that imports `org.vibeerp.platform.foo.Bar` + * via reflection or by adding a Maven coordinate the framework didn't + * authorize. The bytecode linter catches them at install time, before + * they can do any damage. + * + * **What ASM scans:** + * • Type references in class headers (super, interfaces, type + * parameters, annotations) + * • Method signatures (params, return type, throws) + * • Field types and their annotations + * • Method body type references (NEW, GETFIELD, INVOKEVIRTUAL, …) + * • Annotation values that reference types + * + * Anything ASM's ClassReader sees as a type internal name beginning + * with `org/vibeerp/platform/` or `org/vibeerp/pbc/` is a violation. + * + * **What ASM does NOT see:** + * • Strings used in `Class.forName(...)` lookups + * • Reflection that builds class names dynamically + * + * Those are theoretically still possible — a sufficiently determined + * malicious plug-in can always find a way. The linter is "raise the + * bar high enough that an honest mistake is caught and a deliberate + * bypass is unmistakable in the audit log", not "absolute sandboxing." + * For absolute sandboxing the framework would need a Java SecurityManager + * (deprecated in JDK 17+) or to load plug-ins in a separate JVM + * altogether — both deferred indefinitely. + */ +@Component +class PluginLinter { + + private val log = LoggerFactory.getLogger(PluginLinter::class.java) + + /** + * Scan every `.class` entry in the plug-in's JAR. Returns the list + * of violations; empty list means the plug-in is clean. The caller + * decides what to do with violations (typically: refuse to start). + */ + fun lint(wrapper: PluginWrapper): List { + val pluginPath = wrapper.pluginPath + if (pluginPath == null || !Files.isRegularFile(pluginPath)) { + log.warn( + "PluginLinter cannot scan plug-in '{}' — pluginPath {} is not a regular file. " + + "Skipping lint (the plug-in is loaded as an unpacked directory, not a JAR).", + wrapper.pluginId, pluginPath, + ) + return emptyList() + } + + val violations = mutableListOf() + JarFile(pluginPath.toFile()).use { jar -> + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.name.endsWith(".class")) continue + jar.getInputStream(entry).use { stream -> + val reader = ClassReader(stream) + val visitor = ForbiddenImportVisitor( + currentClassName = entry.name.removeSuffix(".class").replace('/', '.'), + violations = violations, + ) + reader.accept(visitor, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + } + } + + return violations + } + + /** + * One violation found by the linter. Includes the offending + * class (the one inside the plug-in JAR) and the forbidden type + * it referenced. The caller logs these and refuses to start the + * plug-in. + */ + data class Violation( + val offendingClass: String, + val forbiddenType: String, + ) + + private class ForbiddenImportVisitor( + private val currentClassName: String, + private val violations: MutableList, + ) : ClassVisitor(Opcodes.ASM9) { + + private fun checkInternalName(internalName: String?) { + if (internalName == null) return + // ASM uses '/' separators in internal names. + if (internalName.startsWith("org/vibeerp/platform/") || + internalName.startsWith("org/vibeerp/pbc/") + ) { + val javaName = internalName.replace('/', '.') + violations += Violation(currentClassName, javaName) + } + } + + private fun checkDescriptor(descriptor: String?) { + if (descriptor == null) return + // Walk the type descriptor for embedded class references. + try { + val type = Type.getType(descriptor) + checkType(type) + } catch (ignore: IllegalArgumentException) { + // Some descriptors (notably annotation values) aren't + // a single Type — best-effort scan via substring match. + FORBIDDEN_PREFIXES.forEach { prefix -> + if (descriptor.contains("L$prefix")) { + val end = descriptor.indexOf(';', startIndex = descriptor.indexOf("L$prefix")) + val sliceEnd = if (end == -1) descriptor.length else end + val raw = descriptor.substring(descriptor.indexOf("L$prefix") + 1, sliceEnd) + violations += Violation(currentClassName, raw.replace('/', '.')) + } + } + } + } + + private fun checkType(type: Type) { + when (type.sort) { + Type.OBJECT -> checkInternalName(type.internalName) + Type.ARRAY -> checkType(type.elementType) + Type.METHOD -> { + checkType(type.returnType) + type.argumentTypes.forEach { checkType(it) } + } + } + } + + override fun visit( + version: Int, + access: Int, + name: String?, + signature: String?, + superName: String?, + interfaces: Array?, + ) { + checkInternalName(superName) + interfaces?.forEach { checkInternalName(it) } + } + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + checkDescriptor(descriptor) + return null + } + + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor? { + checkDescriptor(descriptor) + exceptions?.forEach { checkInternalName(it) } + return object : MethodVisitor(Opcodes.ASM9) { + override fun visitTypeInsn(opcode: Int, type: String?) { + checkInternalName(type) + } + + override fun visitFieldInsn(opcode: Int, owner: String?, name: String?, descriptor: String?) { + checkInternalName(owner) + checkDescriptor(descriptor) + } + + override fun visitMethodInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + isInterface: Boolean, + ) { + checkInternalName(owner) + checkDescriptor(descriptor) + } + } + } + } + + private companion object { + val FORBIDDEN_PREFIXES = listOf( + "org/vibeerp/platform/", + "org/vibeerp/pbc/", + ) + } +} diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/migration/PluginLiquibaseRunner.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/migration/PluginLiquibaseRunner.kt new file mode 100644 index 0000000..9df4896 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/migration/PluginLiquibaseRunner.kt @@ -0,0 +1,114 @@ +package org.vibeerp.platform.plugins.migration + +import liquibase.Liquibase +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import org.pf4j.PluginWrapper +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import javax.sql.DataSource + +/** + * Applies a plug-in's Liquibase changelog at plug-in start time. + * + * **What it does.** When the host starts a plug-in, this runner looks + * for `db/changelog/master.xml` inside the plug-in's classloader. If + * present, it constructs a Liquibase instance backed by the host's + * shared datasource (so plug-in tables live in the same database as + * core tables) and runs `update("")`. The changesets create the + * plug-in's own tables — by convention prefixed `plugin___*` so + * they cannot collide with core or with each other. + * + * **Why one shared datasource and not per-plug-in:** v0.6 keeps the + * deployment story to "one process, one Postgres". Per-plug-in + * datasources (separate connection pools, separate schemas, separate + * credentials) would require a lot more infrastructure and don't pay + * for themselves until there's a real reason to isolate. The convention + * `plugin___*` already provides a clean uninstall story: drop + * everything matching the prefix. + * + * **What it does NOT yet do (deferred):** + * • Verify table-name prefixes — the future plug-in changeset linter + * will refuse changesets that create tables outside the plug-in's + * namespace. + * • Track plug-in DATABASECHANGELOG entries separately — every + * plug-in shares the host's `databasechangelog` table. That's + * fine because the change-id namespace is per-changelog and + * plug-in authors are expected to use unique ids. + * • Roll back on plug-in uninstall — uninstall is operator-confirmed + * and rare; running `dropAll()` against the plug-in changelog + * during stop() would lose data on accidental restart. + * + * Lives in [org.vibeerp.platform.plugins.migration] (its own package) + * so the future plug-in metadata loader, plug-in linter, and other + * lifecycle hooks can sit alongside without polluting the top-level + * namespace. + */ +@Component +class PluginLiquibaseRunner( + private val dataSource: DataSource, +) { + + private val log = LoggerFactory.getLogger(PluginLiquibaseRunner::class.java) + + /** + * Apply the plug-in's changelog if it exists. Returns `true` when + * a changelog was found and applied successfully, `false` when no + * changelog was present (most plug-ins won't have one). Throws + * when a changelog exists but Liquibase fails — the caller treats + * that as a fatal "do not start this plug-in" condition. + */ + fun apply(wrapper: PluginWrapper): Boolean { + val pluginId = wrapper.pluginId + val classLoader = wrapper.pluginClassLoader + + // Probe for the changelog. We use the classloader directly + // (not Spring's resource resolver) so the resource lookup is + // scoped to the plug-in's own JAR — finding a changelog from + // the host's classpath would silently re-apply core migrations + // under the plug-in's name, which would be a great way to + // corrupt a database. + val changelogPath = CHANGELOG_RESOURCE + if (classLoader.getResource(changelogPath) == null) { + log.debug("plug-in '{}' has no {} — skipping migrations", pluginId, changelogPath) + return false + } + + log.info("plug-in '{}' has {} — running plug-in Liquibase migrations", pluginId, changelogPath) + + dataSource.connection.use { conn -> + val database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation(JdbcConnection(conn)) + // Use the plug-in's classloader as the resource accessor so + // Liquibase resolves s relative to the plug-in JAR. + val accessor = ClassLoaderResourceAccessor(classLoader) + val liquibase = Liquibase(changelogPath, accessor, database) + try { + liquibase.update("") + log.info("plug-in '{}' Liquibase migrations applied successfully", pluginId) + } finally { + try { + liquibase.close() + } catch (ignore: Throwable) { + // Liquibase.close() can fail in obscure ways; the + // important state is already committed by update(). + } + } + } + return true + } + + private companion object { + /** + * Plug-in changelog path. Lives under `META-INF/vibe-erp/db/` + * specifically to avoid colliding with the HOST's + * `db/changelog/master.xml`, which is also visible to the + * plug-in classloader via parent-first lookup. Without the + * unique prefix, Liquibase's `ClassLoaderResourceAccessor` + * finds two files at the same path and throws + * `ChangeLogParseException` at install time. + */ + const val CHANGELOG_RESOURCE = "META-INF/vibe-erp/db/changelog.xml" + } +} diff --git a/platform/platform-plugins/src/test/kotlin/org/vibeerp/platform/plugins/lint/PluginLinterTest.kt b/platform/platform-plugins/src/test/kotlin/org/vibeerp/platform/plugins/lint/PluginLinterTest.kt new file mode 100644 index 0000000..5a6abb2 --- /dev/null +++ b/platform/platform-plugins/src/test/kotlin/org/vibeerp/platform/plugins/lint/PluginLinterTest.kt @@ -0,0 +1,188 @@ +package org.vibeerp.platform.plugins.lint + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import org.pf4j.PluginWrapper +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.Attributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest + +/** + * Unit tests for [PluginLinter]. + * + * Strategy: synthesize tiny in-memory class files with ASM, package + * them into a jar in a `@TempDir`, wrap that path in a mocked + * [PluginWrapper], and let the linter scan. Building real plug-in + * JARs from a Gradle subproject would be slower, more brittle, and + * would couple this test to the build layout. + */ +class PluginLinterTest { + + private val linter = PluginLinter() + + @Test + fun `clean class with no forbidden imports passes`(@TempDir tempDir: Path) { + val jar = buildJar(tempDir, mapOf("com/example/CleanPlugin.class" to cleanClassBytes())) + val wrapper = mockWrapper("clean-plugin", jar) + + val violations = linter.lint(wrapper) + + assertThat(violations).isEmpty() + } + + @Test + fun `class that references a forbidden platform type is rejected`(@TempDir tempDir: Path) { + val jar = buildJar( + tempDir, + mapOf("com/example/BadPlugin.class" to classWithFieldOfType("com/example/BadPlugin", "org/vibeerp/platform/Foo")), + ) + val wrapper = mockWrapper("bad-plugin", jar) + + val violations = linter.lint(wrapper) + + assertThat(violations).hasSize(1) + val v = violations.single() + assertThat(v.offendingClass).contains("BadPlugin") + assertThat(v.forbiddenType).contains("org.vibeerp.platform.Foo") + } + + @Test + fun `class that references a forbidden pbc type is rejected`(@TempDir tempDir: Path) { + val jar = buildJar( + tempDir, + mapOf( + "com/example/SneakyPlugin.class" to classWithFieldOfType( + "com/example/SneakyPlugin", + "org/vibeerp/pbc/identity/InternalUserThing", + ), + ), + ) + val wrapper = mockWrapper("sneaky", jar) + + val violations = linter.lint(wrapper) + + assertThat(violations).hasSize(1) + assertThat(violations.single().forbiddenType).contains("org.vibeerp.pbc.identity") + } + + @Test + fun `references to api v1 are allowed`(@TempDir tempDir: Path) { + val jar = buildJar( + tempDir, + mapOf( + "com/example/GoodPlugin.class" to classWithFieldOfType( + "com/example/GoodPlugin", + "org/vibeerp/api/v1/plugin/PluginContext", + ), + ), + ) + val wrapper = mockWrapper("good-plugin", jar) + + val violations = linter.lint(wrapper) + + assertThat(violations).isEmpty() + } + + @Test + fun `multiple forbidden references are all reported`(@TempDir tempDir: Path) { + val jar = buildJar( + tempDir, + mapOf( + "com/example/A.class" to classWithFieldOfType("com/example/A", "org/vibeerp/platform/Foo"), + "com/example/B.class" to classWithFieldOfType("com/example/B", "org/vibeerp/pbc/Bar"), + ), + ) + val wrapper = mockWrapper("multi", jar) + + val violations = linter.lint(wrapper) + + // ASM may visit a single field type from multiple visitors, so the + // exact count can be ≥ 2. The important property is that BOTH + // forbidden types appear and BOTH offending classes appear. + val forbiddenTypes = violations.map { it.forbiddenType }.toSet() + val offendingClasses = violations.map { it.offendingClass }.toSet() + assertThat(forbiddenTypes).contains("org.vibeerp.platform.Foo") + assertThat(forbiddenTypes).contains("org.vibeerp.pbc.Bar") + assertThat(offendingClasses).contains("com.example.A") + assertThat(offendingClasses).contains("com.example.B") + } + + // ─── helpers ─────────────────────────────────────────────────── + + private fun mockWrapper(pluginId: String, jarPath: Path): PluginWrapper { + val wrapper = mockk() + every { wrapper.pluginId } returns pluginId + every { wrapper.pluginPath } returns jarPath + return wrapper + } + + private fun buildJar(tempDir: Path, entries: Map): Path { + val jarPath = tempDir.resolve("test-plugin.jar") + val manifest = Manifest().apply { + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + } + Files.newOutputStream(jarPath).use { fos -> + JarOutputStream(fos, manifest).use { jos -> + for ((name, bytes) in entries) { + jos.putNextEntry(JarEntry(name)) + jos.write(bytes) + jos.closeEntry() + } + } + } + return jarPath + } + + /** Empty class with no references to anything in `org.vibeerp.*`. */ + private fun cleanClassBytes(): ByteArray { + val cw = ClassWriter(0) + cw.visit( + Opcodes.V21, + Opcodes.ACC_PUBLIC, + "com/example/CleanPlugin", + null, + "java/lang/Object", + null, + ) + cw.visitField(Opcodes.ACC_PUBLIC, "name", "Ljava/lang/String;", null, null).visitEnd() + cw.visitEnd() + return cw.toByteArray() + } + + /** + * Class with a single field of the given internal type, which the + * linter sees as a forbidden import if the type is in a forbidden + * package. + */ + private fun classWithFieldOfType(className: String, fieldTypeInternalName: String): ByteArray { + val cw = ClassWriter(0) + cw.visit( + Opcodes.V21, + Opcodes.ACC_PUBLIC, + className, + null, + "java/lang/Object", + null, + ) + cw.visitField( + Opcodes.ACC_PUBLIC, + "secret", + "L$fieldTypeInternalName;", + null, + null, + ).visitEnd() + cw.visitEnd() + return cw.toByteArray() + } +} diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt index 43b7875..00f05fd 100644 --- a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt +++ b/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 import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginRequest import org.vibeerp.api.v1.plugin.PluginResponse +import java.time.Instant +import java.util.UUID import org.pf4j.Plugin as Pf4jPlugin import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin @@ -25,21 +27,30 @@ import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin * but live in different packages, so we use Kotlin import aliases — * `Pf4jPlugin` and `VibeErpPlugin` — to keep the source readable. * - * **What this v0.5 incarnation actually does:** + * **What this v0.6 incarnation actually does:** * 1. Logs `started` / `stopped` via the framework's PluginLogger. - * 2. Registers a single HTTP endpoint at - * `GET /api/v1/plugins/printing-shop/ping` that returns a small - * JSON body. This is the smoke test that proves the entire plug-in - * lifecycle works end-to-end: PF4J loads the JAR, the host calls - * vibe_erp's start, the plug-in registers a handler, the dispatcher - * routes a real HTTP request to it, and the response comes back. + * 2. Owns its own database tables — `plugin_printingshop__plate` and + * `plugin_printingshop__ink_recipe`, declared in the Liquibase + * changelog shipped inside the JAR at `db/changelog/master.xml`. + * The host's PluginLiquibaseRunner applies them at plug-in start. + * 3. Registers seven HTTP endpoints under `/api/v1/plugins/printing-shop/`: + * GET /ping — health check + * GET /echo/{name} — path variable demo + * GET /plates — list plates + * GET /plates/{id} — fetch by id + * POST /plates — create a plate + * GET /inks — list ink recipes + * POST /inks — create an ink recipe + * All CRUD lambdas use `context.jdbc` (the api.v1 typed SQL + * surface) to talk to the plug-in's own tables. The plug-in + * never imports `org.springframework.jdbc.*` or any other host + * internal type. * * **What it does NOT yet do (deferred):** * • register a workflow task handler (depends on P2.1, embedded Flowable) - * • subscribe to events (depends on P1.7, event bus + outbox) - * • own its own database schema (depends on P1.4, plug-in Liquibase) + * • subscribe to events (would need to import api.v1.event.EventBus types) * • register custom permissions (depends on P4.3) - * • express any of the actual printing-shop workflows from the reference docs + * • express the full quote-to-job-card workflow from the reference docs */ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin { @@ -59,6 +70,7 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP this.context = context context.logger.info("printing-shop plug-in started — reference acceptance test active") + // ─── /ping ──────────────────────────────────────────────── context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest -> PluginResponse( status = 200, @@ -70,19 +82,196 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP ), ) } - context.logger.info("registered GET /ping under /api/v1/plugins/printing-shop/") + // ─── /echo/{name} ───────────────────────────────────────── context.endpoints.register(HttpMethod.GET, "/echo/{name}") { request -> val name = request.pathParameters["name"] ?: "unknown" + PluginResponse(body = mapOf("plugin" to "printing-shop", "echoed" to name)) + } + + // ─── /plates ────────────────────────────────────────────── + context.endpoints.register(HttpMethod.GET, "/plates") { _ -> + val plates = context.jdbc.query( + "SELECT id, code, name, width_mm, height_mm, status FROM plugin_printingshop__plate ORDER BY code", + ) { row -> + mapOf( + "id" to row.uuid("id").toString(), + "code" to row.string("code"), + "name" to row.string("name"), + "widthMm" to row.int("width_mm"), + "heightMm" to row.int("height_mm"), + "status" to row.string("status"), + ) + } + PluginResponse(body = plates) + } + + context.endpoints.register(HttpMethod.GET, "/plates/{id}") { request -> + val rawId = request.pathParameters["id"] ?: return@register PluginResponse(404, mapOf("detail" to "missing id")) + val id = try { UUID.fromString(rawId) } catch (ex: IllegalArgumentException) { + return@register PluginResponse(400, mapOf("detail" to "id is not a valid UUID")) + } + val plate = context.jdbc.queryForObject( + "SELECT id, code, name, width_mm, height_mm, status FROM plugin_printingshop__plate WHERE id = :id", + mapOf("id" to id), + ) { row -> + mapOf( + "id" to row.uuid("id").toString(), + "code" to row.string("code"), + "name" to row.string("name"), + "widthMm" to row.int("width_mm"), + "heightMm" to row.int("height_mm"), + "status" to row.string("status"), + ) + } + if (plate == null) { + PluginResponse(404, mapOf("detail" to "plate not found: $id")) + } else { + PluginResponse(body = plate) + } + } + + context.endpoints.register(HttpMethod.POST, "/plates") { request -> + // Parse the JSON body manually — api.v1 doesn't auto-bind. + // Plug-ins use whatever JSON library they like; here we + // do a tiny ad-hoc parse to keep dependencies to zero. + val body = parseJsonObject(request.body.orEmpty()) + val code = body["code"] as? String + ?: return@register PluginResponse(400, mapOf("detail" to "code is required")) + val name = body["name"] as? String + ?: return@register PluginResponse(400, mapOf("detail" to "name is required")) + val widthMm = (body["widthMm"] as? Number)?.toInt() + ?: return@register PluginResponse(400, mapOf("detail" to "widthMm is required")) + val heightMm = (body["heightMm"] as? Number)?.toInt() + ?: return@register PluginResponse(400, mapOf("detail" to "heightMm is required")) + + // Existence check before INSERT — racy but plug-in code + // can't import Spring's DataAccessException without leaking + // Spring onto its compile classpath, and the duplicate + // case is rare enough that an occasional 500 is fine. + val existing = context.jdbc.queryForObject( + "SELECT 1 AS hit FROM plugin_printingshop__plate WHERE code = :code", + mapOf("code" to code), + ) { it.int("hit") } + if (existing != null) { + return@register PluginResponse(409, mapOf("detail" to "plate code '$code' is already taken")) + } + + val id = UUID.randomUUID() + context.jdbc.update( + """ + INSERT INTO plugin_printingshop__plate (id, code, name, width_mm, height_mm, status, created_at, updated_at) + VALUES (:id, :code, :name, :width_mm, :height_mm, 'DRAFT', :now, :now) + """.trimIndent(), + mapOf( + "id" to id, + "code" to code, + "name" to name, + "width_mm" to widthMm, + "height_mm" to heightMm, + "now" to java.sql.Timestamp.from(Instant.now()), + ), + ) PluginResponse( - status = 200, + status = 201, body = mapOf( - "plugin" to "printing-shop", - "echoed" to name, + "id" to id.toString(), + "code" to code, + "name" to name, + "widthMm" to widthMm, + "heightMm" to heightMm, + "status" to "DRAFT", ), ) } - context.logger.info("registered GET /echo/{name} under /api/v1/plugins/printing-shop/") + + // ─── /inks ──────────────────────────────────────────────── + context.endpoints.register(HttpMethod.GET, "/inks") { _ -> + val inks = context.jdbc.query( + "SELECT id, code, name, cmyk_c, cmyk_m, cmyk_y, cmyk_k FROM plugin_printingshop__ink_recipe ORDER BY code", + ) { row -> + mapOf( + "id" to row.uuid("id").toString(), + "code" to row.string("code"), + "name" to row.string("name"), + "cmyk" to mapOf( + "c" to row.int("cmyk_c"), + "m" to row.int("cmyk_m"), + "y" to row.int("cmyk_y"), + "k" to row.int("cmyk_k"), + ), + ) + } + PluginResponse(body = inks) + } + + context.endpoints.register(HttpMethod.POST, "/inks") { request -> + val body = parseJsonObject(request.body.orEmpty()) + val code = body["code"] as? String + ?: return@register PluginResponse(400, mapOf("detail" to "code is required")) + val name = body["name"] as? String + ?: return@register PluginResponse(400, mapOf("detail" to "name is required")) + val cmyk = body["cmyk"] as? Map<*, *> + ?: return@register PluginResponse(400, mapOf("detail" to "cmyk object is required")) + val c = (cmyk["c"] as? Number)?.toInt() ?: 0 + val m = (cmyk["m"] as? Number)?.toInt() ?: 0 + val y = (cmyk["y"] as? Number)?.toInt() ?: 0 + val k = (cmyk["k"] as? Number)?.toInt() ?: 0 + + val existing = context.jdbc.queryForObject( + "SELECT 1 AS hit FROM plugin_printingshop__ink_recipe WHERE code = :code", + mapOf("code" to code), + ) { it.int("hit") } + if (existing != null) { + return@register PluginResponse(409, mapOf("detail" to "ink code '$code' is already taken")) + } + + val id = UUID.randomUUID() + context.jdbc.update( + """ + INSERT INTO plugin_printingshop__ink_recipe (id, code, name, cmyk_c, cmyk_m, cmyk_y, cmyk_k, created_at, updated_at) + VALUES (:id, :code, :name, :c, :m, :y, :k, :now, :now) + """.trimIndent(), + mapOf( + "id" to id, + "code" to code, + "name" to name, + "c" to c, + "m" to m, + "y" to y, + "k" to k, + "now" to java.sql.Timestamp.from(Instant.now()), + ), + ) + PluginResponse( + status = 201, + body = mapOf("id" to id.toString(), "code" to code, "name" to name), + ) + } + + context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/") + } + + /** + * Tiny ad-hoc JSON-object parser. The plug-in deliberately does NOT + * pull in Jackson or kotlinx.serialization — that would either + * conflict with the host's Jackson version (classloader pain) or + * inflate the plug-in JAR. For a v0.6 demo, raw `String.split` is + * enough. A real plug-in author who wants typed deserialization + * ships their own Jackson and uses it inside the lambda. + */ + @Suppress("UNCHECKED_CAST") + private fun parseJsonObject(raw: String): Map { + if (raw.isBlank()) return emptyMap() + // Cheat: use Java's built-in scripting (no extra deps) is too + // heavy. Instead, delegate to whatever Jackson the host already + // has on the classpath via reflection. The plug-in's classloader + // sees the host's Jackson via the parent-first lookup for + // anything not bundled in the plug-in jar. + val mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper") + val mapper = mapperClass.getDeclaredConstructor().newInstance() + val readValue = mapperClass.getMethod("readValue", String::class.java, Class::class.java) + return readValue.invoke(mapper, raw, Map::class.java) as Map } /** diff --git a/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/db/changelog.xml b/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/db/changelog.xml new file mode 100644 index 0000000..d8c2a7a --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/db/changelog.xml @@ -0,0 +1,64 @@ + + + + + + + Create plugin_printingshop__plate table + + CREATE TABLE plugin_printingshop__plate ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + name varchar(256) NOT NULL, + width_mm integer NOT NULL, + height_mm integer NOT NULL, + status varchar(32) NOT NULL DEFAULT 'DRAFT', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + CREATE UNIQUE INDEX plugin_printingshop__plate_code_uk + ON plugin_printingshop__plate (code); + + + DROP TABLE plugin_printingshop__plate; + + + + + Create plugin_printingshop__ink_recipe table + + CREATE TABLE plugin_printingshop__ink_recipe ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + name varchar(256) NOT NULL, + cmyk_c integer NOT NULL, + cmyk_m integer NOT NULL, + cmyk_y integer NOT NULL, + cmyk_k integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + CREATE UNIQUE INDEX plugin_printingshop__ink_recipe_code_uk + ON plugin_printingshop__ink_recipe (code); + + + DROP TABLE plugin_printingshop__ink_recipe; + + + +