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