Commit f51644142c38dbc26949a8cfae54899b83011a85
1 parent
728e3793
feat(api-v1): public plug-in contract — entities, persistence, events, security,…
… i18n, http, plugin lifecycle
Showing
37 changed files
with
1974 additions
and
0 deletions
api/api-v1/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + `java-library` | |
| 4 | + `maven-publish` | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp Public Plug-in API v1 — the only stable contract a plug-in is allowed to depend on." | |
| 8 | + | |
| 9 | +java { | |
| 10 | + toolchain { | |
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 12 | + } | |
| 13 | + withSourcesJar() | |
| 14 | + withJavadocJar() | |
| 15 | +} | |
| 16 | + | |
| 17 | +kotlin { | |
| 18 | + jvmToolchain(21) | |
| 19 | +} | |
| 20 | + | |
| 21 | +// api-v1 has the strictest dependency hygiene in the entire project. | |
| 22 | +// Adding ANY dependency here is effectively adding it to every plug-in | |
| 23 | +// in the ecosystem, forever, until the next major version bump. Be paranoid. | |
| 24 | +dependencies { | |
| 25 | + api(libs.kotlin.stdlib) | |
| 26 | + api(libs.jakarta.validation.api) | |
| 27 | + | |
| 28 | + testImplementation(libs.junit.jupiter) | |
| 29 | + testImplementation(libs.assertk) | |
| 30 | +} | |
| 31 | + | |
| 32 | +tasks.test { | |
| 33 | + useJUnitPlatform() | |
| 34 | +} | |
| 35 | + | |
| 36 | +// We override the inherited project version (0.1.0-SNAPSHOT) with the | |
| 37 | +// independently-versioned API line, because plug-in authors track this. | |
| 38 | +version = providers.gradleProperty("vibeerp.api.version").get() | |
| 39 | + | |
| 40 | +publishing { | |
| 41 | + publications { | |
| 42 | + create<MavenPublication>("maven") { | |
| 43 | + from(components["java"]) | |
| 44 | + groupId = "org.vibeerp" | |
| 45 | + artifactId = "api-v1" | |
| 46 | + version = project.version.toString() | |
| 47 | + pom { | |
| 48 | + name.set("vibe_erp Public Plug-in API v1") | |
| 49 | + description.set("Stable, semver-governed plug-in API for the vibe_erp framework.") | |
| 50 | + url.set("https://github.com/vibeerp/vibe-erp") | |
| 51 | + licenses { | |
| 52 | + license { | |
| 53 | + name.set("Apache License 2.0") | |
| 54 | + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +import java.util.UUID | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Strongly-typed identifier wrapping a UUID. | |
| 7 | + * | |
| 8 | + * Using `Id<User>` instead of raw `UUID` everywhere prevents the classic | |
| 9 | + * mistake of passing a tenant id where a user id was expected — the compiler | |
| 10 | + * catches it. The wrapper is a value class so it has zero runtime overhead. | |
| 11 | + * | |
| 12 | + * @param T phantom type parameter that names what kind of entity this id refers to. | |
| 13 | + */ | |
| 14 | +@JvmInline | |
| 15 | +value class Id<T>(val value: UUID) { | |
| 16 | + override fun toString(): String = value.toString() | |
| 17 | + | |
| 18 | + companion object { | |
| 19 | + fun <T> of(uuid: String): Id<T> = Id(UUID.fromString(uuid)) | |
| 20 | + fun <T> random(): Id<T> = Id(UUID.randomUUID()) | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * A tenant identifier. | |
| 26 | + * | |
| 27 | + * Self-hosted single-customer deployments use a single tenant whose id is the | |
| 28 | + * literal string "default". Hosted multi-tenant deployments have one [TenantId] | |
| 29 | + * per customer organization. | |
| 30 | + */ | |
| 31 | +@JvmInline | |
| 32 | +value class TenantId(val value: String) { | |
| 33 | + init { | |
| 34 | + require(value.isNotBlank()) { "TenantId must not be blank" } | |
| 35 | + require(value.length <= 64) { "TenantId must be at most 64 characters" } | |
| 36 | + require(value.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { | |
| 37 | + "TenantId may only contain letters, digits, '-', or '_' (got '$value')" | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + override fun toString(): String = value | |
| 42 | + | |
| 43 | + companion object { | |
| 44 | + val DEFAULT = TenantId("default") | |
| 45 | + } | |
| 46 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Money.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +import java.math.BigDecimal | |
| 4 | +import java.math.RoundingMode | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A monetary amount in a specific ISO-4217 currency. | |
| 8 | + * | |
| 9 | + * vibe_erp NEVER uses raw `Double` or `BigDecimal` for money in api.v1, because | |
| 10 | + * that strips currency context and invites silent unit conversion bugs across | |
| 11 | + * tenants and locales. Every monetary field on every entity uses [Money]. | |
| 12 | + * | |
| 13 | + * The framework is sold worldwide, so multi-currency is non-negotiable from | |
| 14 | + * day one (CLAUDE.md guardrail #6 / i18n). | |
| 15 | + */ | |
| 16 | +data class Money( | |
| 17 | + val amount: BigDecimal, | |
| 18 | + val currency: Currency, | |
| 19 | +) : Comparable<Money> { | |
| 20 | + | |
| 21 | + init { | |
| 22 | + require(amount.scale() <= currency.defaultFractionDigits) { | |
| 23 | + "Money amount scale ${amount.scale()} exceeds currency ${currency.code} fraction digits ${currency.defaultFractionDigits}" | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + operator fun plus(other: Money): Money { | |
| 28 | + require(currency == other.currency) { | |
| 29 | + "Cannot add Money in different currencies: ${currency.code} vs ${other.currency.code}" | |
| 30 | + } | |
| 31 | + return Money(amount + other.amount, currency) | |
| 32 | + } | |
| 33 | + | |
| 34 | + operator fun minus(other: Money): Money { | |
| 35 | + require(currency == other.currency) { | |
| 36 | + "Cannot subtract Money in different currencies: ${currency.code} vs ${other.currency.code}" | |
| 37 | + } | |
| 38 | + return Money(amount - other.amount, currency) | |
| 39 | + } | |
| 40 | + | |
| 41 | + operator fun times(multiplier: BigDecimal): Money = | |
| 42 | + Money(amount.multiply(multiplier).setScale(currency.defaultFractionDigits, RoundingMode.HALF_EVEN), currency) | |
| 43 | + | |
| 44 | + override fun compareTo(other: Money): Int { | |
| 45 | + require(currency == other.currency) { | |
| 46 | + "Cannot compare Money in different currencies: ${currency.code} vs ${other.currency.code}" | |
| 47 | + } | |
| 48 | + return amount.compareTo(other.amount) | |
| 49 | + } | |
| 50 | + | |
| 51 | + companion object { | |
| 52 | + fun zero(currency: Currency): Money = | |
| 53 | + Money(BigDecimal.ZERO.setScale(currency.defaultFractionDigits), currency) | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * Minimal ISO-4217 currency descriptor. | |
| 59 | + * | |
| 60 | + * Kept intentionally tiny in api.v1: code + default fraction digits. The | |
| 61 | + * full currency catalog (display names, symbols, regional formatting) lives | |
| 62 | + * in the i18n layer and is not part of the stable plug-in contract. | |
| 63 | + */ | |
| 64 | +data class Currency(val code: String, val defaultFractionDigits: Int) { | |
| 65 | + init { | |
| 66 | + require(code.length == 3 && code.all { it.isUpperCase() }) { | |
| 67 | + "Currency code must be 3 uppercase letters (got '$code')" | |
| 68 | + } | |
| 69 | + require(defaultFractionDigits in 0..6) { | |
| 70 | + "defaultFractionDigits out of range: $defaultFractionDigits" | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + companion object { | |
| 75 | + val USD = Currency("USD", 2) | |
| 76 | + val EUR = Currency("EUR", 2) | |
| 77 | + val CNY = Currency("CNY", 2) | |
| 78 | + val JPY = Currency("JPY", 0) | |
| 79 | + val GBP = Currency("GBP", 2) | |
| 80 | + } | |
| 81 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Quantity.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +import java.math.BigDecimal | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * A quantity in a specific unit of measure. | |
| 7 | + * | |
| 8 | + * Like [Money], quantities carry their unit so the compiler and the runtime | |
| 9 | + * can refuse to add 5 kg to 5 m. The full unit catalog (with conversion | |
| 10 | + * factors and locale-aware display) lives in pbc-catalog and is not part of | |
| 11 | + * the stable api.v1 surface — only the [UnitOfMeasure] descriptor is. | |
| 12 | + */ | |
| 13 | +data class Quantity( | |
| 14 | + val amount: BigDecimal, | |
| 15 | + val unit: UnitOfMeasure, | |
| 16 | +) : Comparable<Quantity> { | |
| 17 | + | |
| 18 | + operator fun plus(other: Quantity): Quantity { | |
| 19 | + require(unit == other.unit) { | |
| 20 | + "Cannot add Quantity in different units: ${unit.code} vs ${other.unit.code}. " + | |
| 21 | + "Convert through pbc-catalog before arithmetic." | |
| 22 | + } | |
| 23 | + return Quantity(amount + other.amount, unit) | |
| 24 | + } | |
| 25 | + | |
| 26 | + operator fun minus(other: Quantity): Quantity { | |
| 27 | + require(unit == other.unit) { | |
| 28 | + "Cannot subtract Quantity in different units: ${unit.code} vs ${other.unit.code}" | |
| 29 | + } | |
| 30 | + return Quantity(amount - other.amount, unit) | |
| 31 | + } | |
| 32 | + | |
| 33 | + override fun compareTo(other: Quantity): Int { | |
| 34 | + require(unit == other.unit) { | |
| 35 | + "Cannot compare Quantity in different units: ${unit.code} vs ${other.unit.code}" | |
| 36 | + } | |
| 37 | + return amount.compareTo(other.amount) | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Unit of measure descriptor. | |
| 43 | + * | |
| 44 | + * `code` is a stable identifier (e.g. "kg", "m", "ea", "sheet"). Display | |
| 45 | + * names and conversions are NOT part of api.v1 — they live in pbc-catalog. | |
| 46 | + */ | |
| 47 | +data class UnitOfMeasure(val code: String) { | |
| 48 | + init { | |
| 49 | + require(code.isNotBlank() && code.length <= 16) { | |
| 50 | + "UnitOfMeasure code must be 1-16 characters (got '$code')" | |
| 51 | + } | |
| 52 | + } | |
| 53 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Result.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A typed success-or-failure value used across the api.v1 surface. | |
| 5 | + * | |
| 6 | + * vibe_erp prefers explicit Result types in plug-in-facing APIs over thrown | |
| 7 | + * exceptions for *expected* failures (validation errors, permission denials, | |
| 8 | + * not-found, optimistic-locking conflicts). Exceptions remain appropriate for | |
| 9 | + * unexpected, infrastructural failures (the database is down, etc.). | |
| 10 | + * | |
| 11 | + * Why: plug-ins are loaded into a host process and a thrown exception that | |
| 12 | + * crosses a classloader boundary can be very hard to handle correctly. A typed | |
| 13 | + * Result keeps the contract honest. | |
| 14 | + */ | |
| 15 | +sealed interface Result<out T, out E> { | |
| 16 | + data class Ok<T>(val value: T) : Result<T, Nothing> | |
| 17 | + data class Err<E>(val error: E) : Result<Nothing, E> | |
| 18 | + | |
| 19 | + fun isOk(): Boolean = this is Ok | |
| 20 | + fun isErr(): Boolean = this is Err | |
| 21 | + | |
| 22 | + fun valueOrNull(): T? = (this as? Ok)?.value | |
| 23 | + fun errorOrNull(): E? = (this as? Err)?.error | |
| 24 | + | |
| 25 | + companion object { | |
| 26 | + fun <T> ok(value: T): Result<T, Nothing> = Ok(value) | |
| 27 | + fun <E> err(error: E): Result<Nothing, E> = Err(error) | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +inline fun <T, E, R> Result<T, E>.map(transform: (T) -> R): Result<R, E> = when (this) { | |
| 32 | + is Result.Ok -> Result.Ok(transform(value)) | |
| 33 | + is Result.Err -> this | |
| 34 | +} | |
| 35 | + | |
| 36 | +inline fun <T, E, R> Result<T, E>.flatMap(transform: (T) -> Result<R, E>): Result<R, E> = when (this) { | |
| 37 | + is Result.Ok -> transform(value) | |
| 38 | + is Result.Err -> this | |
| 39 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/AuditedEntity.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.security.PrincipalId | |
| 4 | +import java.time.Instant | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * An [Entity] that carries who/when audit columns automatically maintained by | |
| 8 | + * the platform's JPA listener. | |
| 9 | + * | |
| 10 | + * Why this exists as a *separate* interface rather than fields on [Entity]: | |
| 11 | + * - Some entities (config rows, lookup tables, immutable event records) do | |
| 12 | + * not need an updated-at column and forcing one on them invites lying | |
| 13 | + * timestamps. | |
| 14 | + * - It gives the platform a typed hook to know "this entity wants the audit | |
| 15 | + * listener" without reflection-scanning every class. | |
| 16 | + * - It documents in the type system that the four columns are populated by | |
| 17 | + * the framework, not by the plug-in. Plug-ins should treat the setters | |
| 18 | + * as read-only at the application level — the values are filled in at | |
| 19 | + * flush time from the current [org.vibeerp.api.v1.security.Principal]. | |
| 20 | + * | |
| 21 | + * The four columns mirror what every serious ERP records on every business | |
| 22 | + * row and what GDPR/SOX-style audits expect to find. | |
| 23 | + */ | |
| 24 | +interface AuditedEntity : Entity { | |
| 25 | + | |
| 26 | + /** When this row was first inserted. UTC. Set once, never updated. */ | |
| 27 | + val createdAt: Instant | |
| 28 | + | |
| 29 | + /** Principal that performed the insert. */ | |
| 30 | + val createdBy: PrincipalId | |
| 31 | + | |
| 32 | + /** When this row was last updated. UTC. Equal to [createdAt] until first update. */ | |
| 33 | + val updatedAt: Instant | |
| 34 | + | |
| 35 | + /** Principal that performed the most recent update. */ | |
| 36 | + val updatedBy: PrincipalId | |
| 37 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/CustomField.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Declarative description of a custom field added to an existing entity. | |
| 5 | + * | |
| 6 | + * Custom fields are how the framework satisfies guardrail #1 ("core stays | |
| 7 | + * domain-agnostic") in practice: a printing shop that needs a `plate_gauge` | |
| 8 | + * column on the catalog item entity does not patch `pbc-catalog`. They add | |
| 9 | + * a [CustomField] row through the no-code UI (Tier 1) or through a plug-in | |
| 10 | + * manifest (Tier 2), and the JSONB `ext` column on the catalog item table | |
| 11 | + * starts carrying the value the same instant. | |
| 12 | + * | |
| 13 | + * The form builder, list view designer, OpenAPI generator, AI agent function | |
| 14 | + * catalog, and DSAR/erasure jobs all consume this descriptor. There is | |
| 15 | + * intentionally no parallel source of truth. | |
| 16 | + * | |
| 17 | + * @property key Stable identifier for this field within [targetEntity]. Must be | |
| 18 | + * stable across upgrades because it is the JSON key inside the `ext` column. | |
| 19 | + * Convention: `snake_case`, prefixed by the owning plug-in or PBC, e.g. | |
| 20 | + * `printingshop_plate_gauge`. | |
| 21 | + * @property targetEntity Name of the entity this field is attached to. Must | |
| 22 | + * be discoverable through [EntityRegistry] at registration time. | |
| 23 | + * @property type The field's data type — see [FieldType]. | |
| 24 | + * @property required Whether the value must be present on insert/update. | |
| 25 | + * @property pii Whether this field contains personally identifiable information. | |
| 26 | + * Drives automatic DSAR exports and erasure jobs (architecture spec section 8, | |
| 27 | + * "Data sovereignty"). Setting this wrong is a compliance bug, not a styling | |
| 28 | + * choice — when in doubt, mark it `true`. | |
| 29 | + * @property labelTranslations Locale code → human-readable label. The framework | |
| 30 | + * refuses to render a custom field that has no label for the current locale | |
| 31 | + * *and* no entry for the configured fallback locale; this prevents the | |
| 32 | + * classic "key string leaked to the UI" failure mode. | |
| 33 | + */ | |
| 34 | +data class CustomField( | |
| 35 | + val key: String, | |
| 36 | + val targetEntity: String, | |
| 37 | + val type: FieldType, | |
| 38 | + val required: Boolean = false, | |
| 39 | + val pii: Boolean = false, | |
| 40 | + val labelTranslations: Map<String, String> = emptyMap(), | |
| 41 | +) { | |
| 42 | + init { | |
| 43 | + require(key.isNotBlank()) { "CustomField key must not be blank" } | |
| 44 | + require(key.length <= 64) { "CustomField key must be at most 64 characters (got '$key')" } | |
| 45 | + require(key.all { it.isLetterOrDigit() || it == '_' }) { | |
| 46 | + "CustomField key may only contain letters, digits, and '_' (got '$key')" | |
| 47 | + } | |
| 48 | + require(targetEntity.isNotBlank()) { "CustomField targetEntity must not be blank" } | |
| 49 | + } | |
| 50 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | +import org.vibeerp.api.v1.core.TenantId | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Marker interface for every domain entity that lives in the vibe_erp data model. | |
| 8 | + * | |
| 9 | + * Why this exists at all: | |
| 10 | + * - It enforces, at the type level, that every entity carries a tenant id. | |
| 11 | + * Multi-tenancy is an architectural guardrail (CLAUDE.md #5) and the cheapest | |
| 12 | + * place to enforce it is the type system. A repository signature like | |
| 13 | + * `Repository<T : Entity>` cannot be instantiated for a type that "forgot" | |
| 14 | + * its tenant id. | |
| 15 | + * - It gives the runtime a single hook for tenant filtering, JSONB `ext` | |
| 16 | + * handling, and audit interception without each PBC reinventing it. | |
| 17 | + * - It is intentionally minimal: no equality, no hashCode, no lifecycle | |
| 18 | + * callbacks. Those are concerns of `platform.persistence`, not the public | |
| 19 | + * contract. Adding them later is a breaking change; leaving them out is not. | |
| 20 | + * | |
| 21 | + * Plug-ins implement this on their own data classes. They never see the | |
| 22 | + * underlying JPA `@Entity` annotation — that lives in the platform's internal | |
| 23 | + * mapping layer. | |
| 24 | + * | |
| 25 | + * ``` | |
| 26 | + * data class Plate( | |
| 27 | + * override val id: Id<Plate>, | |
| 28 | + * override val tenantId: TenantId, | |
| 29 | + * val gauge: Quantity, | |
| 30 | + * ) : Entity | |
| 31 | + * ``` | |
| 32 | + */ | |
| 33 | +interface Entity { | |
| 34 | + /** | |
| 35 | + * The strongly-typed identifier for this entity. Using `Id<*>` here (rather | |
| 36 | + * than `Id<Self>`) keeps the marker contravariant-friendly so polymorphic | |
| 37 | + * collections of `Entity` can be passed across api.v1 boundaries without | |
| 38 | + * generic gymnastics. Concrete implementations should narrow the type | |
| 39 | + * parameter to themselves. | |
| 40 | + */ | |
| 41 | + val id: Id<*> | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * The tenant that owns this row. Required on every business entity, even in | |
| 45 | + * single-tenant self-hosted deployments (where the value is | |
| 46 | + * [TenantId.DEFAULT]) — there is intentionally one code path for both | |
| 47 | + * deployment shapes. | |
| 48 | + */ | |
| 49 | + val tenantId: TenantId | |
| 50 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/EntityRegistry.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Runtime catalog of every entity the framework knows about — core PBC | |
| 5 | + * entities, plug-in entities, and Tier 1 user-defined entities. | |
| 6 | + * | |
| 7 | + * Why this is part of api.v1: | |
| 8 | + * - Plug-ins need to introspect entities at runtime to render generic UIs, | |
| 9 | + * drive the OpenAPI generator, and resolve [FieldType.Reference] targets, | |
| 10 | + * *without* depending on the concrete PBC that owns each entity (which | |
| 11 | + * would violate guardrail #9, "PBC boundaries are sacred"). | |
| 12 | + * - It is the seam through which Tier 1 user-defined entities and Tier 2 | |
| 13 | + * plug-in entities look identical to consumers. Neither caller cares | |
| 14 | + * where an entity descriptor came from. | |
| 15 | + * | |
| 16 | + * The registry is read-only from the plug-in's perspective. Registration | |
| 17 | + * happens during plug-in startup via [org.vibeerp.api.v1.plugin.PluginContext] | |
| 18 | + * helpers that the platform implements internally. | |
| 19 | + */ | |
| 20 | +interface EntityRegistry { | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * Look up an entity descriptor by its registered name. | |
| 24 | + * | |
| 25 | + * @return the descriptor, or `null` if no entity with that name exists. | |
| 26 | + * Plug-ins should not throw on a miss — entities can be uninstalled | |
| 27 | + * while a workflow that referenced them is mid-flight. | |
| 28 | + */ | |
| 29 | + fun describe(entityName: String): EntityDescriptor? | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Snapshot of every currently-registered entity name. Default | |
| 33 | + * implementation returns an empty set so adding new lookup methods later | |
| 34 | + * (e.g. filtered by source) is non-breaking for existing host | |
| 35 | + * implementations. | |
| 36 | + */ | |
| 37 | + fun entityNames(): Set<String> = emptySet() | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Lightweight description of an entity, returned by [EntityRegistry.describe]. | |
| 42 | + * | |
| 43 | + * Kept intentionally small. The full mapping (table name, column types, | |
| 44 | + * Hibernate metadata) lives in the platform internals; api.v1 exposes only | |
| 45 | + * what plug-ins legitimately need to render generic UIs and validate input. | |
| 46 | + * | |
| 47 | + * @property name Stable, globally unique entity name, e.g. `catalog.item` or | |
| 48 | + * `plugin_printingshop.plate_spec`. | |
| 49 | + * @property pluginId The plug-in id that owns this entity, or `null` if it | |
| 50 | + * belongs to a core PBC. | |
| 51 | + * @property customFields Custom fields currently declared against this entity | |
| 52 | + * for the calling tenant. Note: this list is *tenant-scoped* — two tenants | |
| 53 | + * on the same host can see different custom fields on the same entity. | |
| 54 | + * @property auditable Whether the entity implements [AuditedEntity] and | |
| 55 | + * therefore carries the four audit columns. | |
| 56 | + */ | |
| 57 | +data class EntityDescriptor( | |
| 58 | + val name: String, | |
| 59 | + val pluginId: String? = null, | |
| 60 | + val customFields: List<CustomField> = emptyList(), | |
| 61 | + val auditable: Boolean = false, | |
| 62 | +) { | |
| 63 | + init { | |
| 64 | + require(name.isNotBlank()) { "EntityDescriptor name must not be blank" } | |
| 65 | + } | |
| 66 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/FieldType.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The closed set of field types that vibe_erp's metadata layer can describe. | |
| 5 | + * | |
| 6 | + * Why this is a sealed interface and not an enum: | |
| 7 | + * - Several types carry their own configuration (max length on STRING, | |
| 8 | + * precision on DECIMAL, target entity on REFERENCE, allowed values on | |
| 9 | + * ENUM). An enum cannot do that without a parallel `Map<Type, Config>` | |
| 10 | + * table that the type system cannot keep in sync. | |
| 11 | + * - It is closed deliberately. Adding a new field type is an api.v1 change | |
| 12 | + * requiring a release; allowing arbitrary new field types in user metadata | |
| 13 | + * would defeat the form designer, the OpenAPI generator, the AI function | |
| 14 | + * catalog, and the PII/DSAR pipeline, all of which switch on this set. | |
| 15 | + * | |
| 16 | + * Field types are used in two places: | |
| 17 | + * 1. [CustomField] declarations, where a key user adds a column to an | |
| 18 | + * existing entity through the no-code UI. | |
| 19 | + * 2. Plug-in entity descriptors registered through [EntityRegistry], so the | |
| 20 | + * same UI machinery (form builder, list view, OpenAPI) works on plug-in | |
| 21 | + * entities without the plug-in shipping its own React code. | |
| 22 | + * | |
| 23 | + * Storage shape: | |
| 24 | + * - Custom-field values live in the JSONB `ext` column on the host entity's | |
| 25 | + * table (architecture spec section 8). The field type drives JSON encoding, | |
| 26 | + * GIN-index strategy, validation, and OpenAPI schema generation. | |
| 27 | + */ | |
| 28 | +sealed interface FieldType { | |
| 29 | + | |
| 30 | + /** Free text. [maxLength] caps storage and form input length. */ | |
| 31 | + data class String(val maxLength: Int = 255) : FieldType { | |
| 32 | + init { | |
| 33 | + require(maxLength in 1..1_048_576) { "STRING maxLength out of range: $maxLength" } | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /** 64-bit signed integer. */ | |
| 38 | + data object Integer : FieldType | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * Fixed-point decimal stored as a JSON string to preserve scale. | |
| 42 | + * [precision] is total digits, [scale] is digits after the decimal point. | |
| 43 | + */ | |
| 44 | + data class Decimal(val precision: Int = 19, val scale: Int = 4) : FieldType { | |
| 45 | + init { | |
| 46 | + require(precision in 1..38) { "DECIMAL precision out of range: $precision" } | |
| 47 | + require(scale in 0..precision) { "DECIMAL scale out of range: $scale" } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** Boolean. */ | |
| 52 | + data object Boolean : FieldType | |
| 53 | + | |
| 54 | + /** Calendar date with no time-of-day and no zone. */ | |
| 55 | + data object Date : FieldType | |
| 56 | + | |
| 57 | + /** Instant on the timeline (stored UTC, rendered in the user's locale). */ | |
| 58 | + data object DateTime : FieldType | |
| 59 | + | |
| 60 | + /** RFC-4122 UUID. Distinct from [Reference] — a free-floating identifier. */ | |
| 61 | + data object Uuid : FieldType | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * A monetary value. The currency is part of the *value*, not the field | |
| 65 | + * descriptor — see [org.vibeerp.api.v1.core.Money]. This means a single | |
| 66 | + * `total_amount` field can legitimately hold mixed-currency rows. | |
| 67 | + */ | |
| 68 | + data object Money : FieldType | |
| 69 | + | |
| 70 | + /** A quantity with a unit of measure attached to the value. */ | |
| 71 | + data object Quantity : FieldType | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * A foreign-key-style pointer to another entity by name. | |
| 75 | + * | |
| 76 | + * The reference is resolved by [EntityRegistry] at runtime, which is what | |
| 77 | + * lets a Tier 1 user create a custom field that points at a plug-in | |
| 78 | + * entity without anyone writing JPA mappings. | |
| 79 | + */ | |
| 80 | + data class Reference(val targetEntity: kotlin.String) : FieldType { | |
| 81 | + init { | |
| 82 | + require(targetEntity.isNotBlank()) { "REFERENCE targetEntity must not be blank" } | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * A closed set of allowed string values. The values themselves are stored | |
| 88 | + * as plain strings; their human-readable labels live in the i18n bundles | |
| 89 | + * keyed by `<entity>.<field>.<value>`. | |
| 90 | + */ | |
| 91 | + data class Enum(val allowedValues: List<kotlin.String>) : FieldType { | |
| 92 | + init { | |
| 93 | + require(allowedValues.isNotEmpty()) { "ENUM must declare at least one allowed value" } | |
| 94 | + require(allowedValues.toSet().size == allowedValues.size) { "ENUM allowedValues must be unique" } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * Arbitrary structured JSON. Use sparingly — JSON fields are opaque to | |
| 100 | + * the form builder, the list view, and the OpenAPI schema generator. | |
| 101 | + * Prefer a structured type when one fits. | |
| 102 | + */ | |
| 103 | + data object Json : FieldType | |
| 104 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.event | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | +import org.vibeerp.api.v1.core.TenantId | |
| 5 | +import java.time.Instant | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Base contract for every domain event published on the vibe_erp event bus. | |
| 9 | + * | |
| 10 | + * Why a sealed-style typed contract instead of "publish any object": | |
| 11 | + * - PBC boundaries are sacred (CLAUDE.md guardrail #9). PBCs talk to each | |
| 12 | + * other only through events or through `api.v1.ext.<pbc>` interfaces. If | |
| 13 | + * events were untyped, every consumer would have to know every producer's | |
| 14 | + * class, which is exactly the coupling we are trying to avoid. | |
| 15 | + * - The standard envelope (`eventId`, `tenantId`, `occurredAt`, | |
| 16 | + * `aggregateType`, `aggregateId`) is what the platform's outbox table, | |
| 17 | + * audit log, and future Kafka/NATS bridge all rely on. Plug-ins that | |
| 18 | + * forget any of these fields cannot misroute or replay events. | |
| 19 | + * | |
| 20 | + * Note that this is `interface`, not `sealed interface`: plug-ins must be | |
| 21 | + * free to *extend* the hierarchy with their own concrete event classes. | |
| 22 | + * "Sealed" here means "every plug-in event must extend this sealed envelope", | |
| 23 | + * not "the set of envelope shapes is closed". A truly closed hierarchy would | |
| 24 | + * make plug-ins impossible. | |
| 25 | + * | |
| 26 | + * ``` | |
| 27 | + * data class PlateApproved( | |
| 28 | + * override val eventId: Id<DomainEvent> = Id.random(), | |
| 29 | + * override val tenantId: TenantId, | |
| 30 | + * override val occurredAt: Instant = Instant.now(), | |
| 31 | + * val plateId: Id<*>, | |
| 32 | + * ) : DomainEvent { | |
| 33 | + * override val aggregateType = "printingshop.plate" | |
| 34 | + * override val aggregateId = plateId.toString() | |
| 35 | + * } | |
| 36 | + * ``` | |
| 37 | + */ | |
| 38 | +interface DomainEvent { | |
| 39 | + | |
| 40 | + /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */ | |
| 41 | + val eventId: Id<DomainEvent> | |
| 42 | + | |
| 43 | + /** Tenant the event belongs to. Carried explicitly because background consumers run outside any HTTP request. */ | |
| 44 | + val tenantId: TenantId | |
| 45 | + | |
| 46 | + /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */ | |
| 47 | + val occurredAt: Instant | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Logical name of the aggregate this event is about, e.g. | |
| 51 | + * `orders_sales.order` or `plugin_printingshop.plate`. Used for routing, | |
| 52 | + * audit views, and the future event-streaming bridge. | |
| 53 | + */ | |
| 54 | + val aggregateType: String | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * String form of the aggregate's id. String (not [Id]) because some | |
| 58 | + * aggregates are keyed by composite or natural keys and the bus must | |
| 59 | + * route them all. | |
| 60 | + */ | |
| 61 | + val aggregateId: String | |
| 62 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.event | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Publish/subscribe contract for domain events. | |
| 5 | + * | |
| 6 | + * The host's implementation is backed by an in-process bus plus a Postgres | |
| 7 | + * **outbox** table (architecture spec section 9, "Cross-cutting concerns"). | |
| 8 | + * The outbox is what makes "modular monolith now, splittable later" real: | |
| 9 | + * the day the framework grows a Kafka or NATS bridge, the existing | |
| 10 | + * publish/subscribe code in every PBC and plug-in keeps working unchanged. | |
| 11 | + * | |
| 12 | + * Why this is the only way plug-ins emit events: | |
| 13 | + * - The publish path goes through the platform so it can attach the current | |
| 14 | + * tenant, principal, and request id automatically; a plug-in that bypasses | |
| 15 | + * [EventBus] would lose all of that and break the audit trail. | |
| 16 | + * - The subscribe path is the platform's only place to track listener | |
| 17 | + * registrations per plug-in classloader, so unloading a plug-in cleanly | |
| 18 | + * removes its listeners. | |
| 19 | + * | |
| 20 | + * Both methods are deliberately non-suspending and Java-friendly (`Class<E>` | |
| 21 | + * over `KClass<E>`) so the contract is callable from any JVM language a | |
| 22 | + * plug-in author chooses. | |
| 23 | + */ | |
| 24 | +interface EventBus { | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * Publish an event. The bus is responsible for delivering it to every | |
| 28 | + * matching listener and for persisting an outbox row inside the current | |
| 29 | + * transaction (so a publish followed by a rollback never escapes). | |
| 30 | + */ | |
| 31 | + fun publish(event: DomainEvent) | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * Register a listener for events of [eventType] (and its subtypes). | |
| 35 | + * | |
| 36 | + * The listener stays registered until the plug-in is stopped, at which | |
| 37 | + * point the platform tears it down automatically — plug-ins do not | |
| 38 | + * need (and should not have) a manual `unsubscribe` method. | |
| 39 | + */ | |
| 40 | + fun <E : DomainEvent> subscribe(eventType: Class<E>, listener: EventListener<E>) | |
| 41 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventListener.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.event | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Single-method listener for domain events. | |
| 5 | + * | |
| 6 | + * Declared as `fun interface` so plug-ins can register listeners with a Kotlin | |
| 7 | + * lambda or a method reference, without writing an anonymous-class boilerplate | |
| 8 | + * shell: | |
| 9 | + * | |
| 10 | + * ``` | |
| 11 | + * eventBus.subscribe(PlateApproved::class.java) { event -> | |
| 12 | + * logger.info("Plate ${event.plateId} approved") | |
| 13 | + * } | |
| 14 | + * ``` | |
| 15 | + * | |
| 16 | + * Listeners run on the thread the bus chooses, which may be the publisher's | |
| 17 | + * thread (in-process synchronous), a worker thread (in-process async), or a | |
| 18 | + * dedicated outbox-drain thread when reliability is required. Implementations | |
| 19 | + * MUST be thread-safe and MUST NOT assume the publisher's transaction is | |
| 20 | + * still open — if a listener needs its own transaction, it must request one | |
| 21 | + * through [org.vibeerp.api.v1.persistence.Transaction]. | |
| 22 | + */ | |
| 23 | +fun interface EventListener<E : DomainEvent> { | |
| 24 | + fun handle(event: E) | |
| 25 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.ext.identity | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | +import org.vibeerp.api.v1.core.TenantId | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Cross-PBC facade for the identity bounded context. | |
| 8 | + * | |
| 9 | + * **This file is the proof that PBCs talk to each other through `api.v1`, | |
| 10 | + * not directly.** Guardrail #9 ("PBC boundaries are sacred") is not just a | |
| 11 | + * convention; it is enforced by the Gradle build, which forbids | |
| 12 | + * `pbc-orders-sales` from declaring `pbc-identity` as a dependency. The only | |
| 13 | + * way `pbc-orders-sales` (or any plug-in) can ask "who is the user with this | |
| 14 | + * username?" is by injecting [IdentityApi] — an interface declared here, in | |
| 15 | + * api.v1, and implemented by `pbc-identity` at runtime. | |
| 16 | + * | |
| 17 | + * If [IdentityApi] grows a method that leaks identity-internal types, the | |
| 18 | + * abstraction has failed and the method must be reshaped. The whole point of | |
| 19 | + * this package is to expose intent (`UserRef`) without exposing the storage | |
| 20 | + * model (`IdentityUserEntity`, `IdentityUserRow`, etc.). | |
| 21 | + * | |
| 22 | + * Future PBCs will get sibling packages (`api.v1.ext.catalog`, | |
| 23 | + * `api.v1.ext.inventory`, …) following the same pattern. | |
| 24 | + */ | |
| 25 | +interface IdentityApi { | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * Look up a user by their unique username within the current tenant. | |
| 29 | + * Returns `null` when no such user exists or when the calling principal | |
| 30 | + * lacks permission to read user records. | |
| 31 | + */ | |
| 32 | + fun findUserByUsername(username: String): UserRef? | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * Look up a user by id. The id is `Id<*>` rather than `Id<UserRef>` so | |
| 36 | + * callers do not have to know the exact entity type — they pass the id | |
| 37 | + * they have, and the implementation does the matching internally. | |
| 38 | + */ | |
| 39 | + fun findUserById(id: Id<*>): UserRef? | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Minimal, safe-to-publish view of a user. | |
| 44 | + * | |
| 45 | + * Notably absent: password hash, MFA secrets, last login IP, anything from | |
| 46 | + * the `identity__user_security` table. Anything that does not appear here | |
| 47 | + * cannot be observed cross-PBC, and that is intentional — adding a field is | |
| 48 | + * a deliberate api.v1 change and triggers a security review. | |
| 49 | + * | |
| 50 | + * @property id Stable user id. | |
| 51 | + * @property username Login handle. Unique within [tenantId]. | |
| 52 | + * @property tenantId Owning tenant. | |
| 53 | + * @property displayName Human-readable name for UI rendering. Falls back to | |
| 54 | + * [username] when the user has not set one. | |
| 55 | + * @property enabled Whether the account is active. A disabled user can still | |
| 56 | + * appear as an audit-column foreign key, which is why this field exists at | |
| 57 | + * all — callers must check it before treating the user as a current actor. | |
| 58 | + */ | |
| 59 | +data class UserRef( | |
| 60 | + val id: Id<UserRef>, | |
| 61 | + val username: String, | |
| 62 | + val tenantId: TenantId, | |
| 63 | + val displayName: String, | |
| 64 | + val enabled: Boolean, | |
| 65 | +) | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/form/FormSchema.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.form | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A form definition shipped by a plug-in or stored in the metadata table. | |
| 5 | + * | |
| 6 | + * Why two strings instead of a structured Kotlin model: | |
| 7 | + * - The form designer in the web UI works in JSON Schema + UI Schema (the | |
| 8 | + * same shape used by Frappe Doctype, ERPNext form layouts, and the | |
| 9 | + * `@rjsf/core` React renderer). Re-modeling that as Kotlin classes would | |
| 10 | + * create a parallel source of truth that drifts the moment a renderer | |
| 11 | + * feature is added. | |
| 12 | + * - api.v1 must not depend on a specific JSON parser (Jackson is forbidden). | |
| 13 | + * Holding the JSON as opaque strings means the platform parses it once, | |
| 14 | + * inside the platform layer, and plug-ins never need a JSON dependency | |
| 15 | + * just to ship a form. | |
| 16 | + * - The richer typed wrapper API lands in v0.2 of the api.v1 line as a | |
| 17 | + * purely additive layer. Until then, this is the smallest contract that | |
| 18 | + * lets a plug-in ship a working form. | |
| 19 | + * | |
| 20 | + * @property jsonSchema A JSON Schema (draft 2020-12) document describing | |
| 21 | + * the data shape of the form. The host validates user input against this | |
| 22 | + * schema before passing it to the plug-in. | |
| 23 | + * @property uiSchema A UI Schema document (the `@rjsf/core` convention) | |
| 24 | + * describing widget choices, ordering, and layout hints. May be empty | |
| 25 | + * string when the default rendering is acceptable. | |
| 26 | + * @property version Monotonically increasing schema version. The host | |
| 27 | + * stores submitted form values tagged with the version they were captured | |
| 28 | + * under so a plug-in upgrade does not retroactively invalidate old | |
| 29 | + * records. Bump this when the [jsonSchema] changes shape, not when the | |
| 30 | + * [uiSchema] is merely restyled. | |
| 31 | + */ | |
| 32 | +data class FormSchema( | |
| 33 | + val jsonSchema: String, | |
| 34 | + val uiSchema: String, | |
| 35 | + val version: Int, | |
| 36 | +) { | |
| 37 | + init { | |
| 38 | + require(jsonSchema.isNotBlank()) { "FormSchema jsonSchema must not be blank" } | |
| 39 | + require(version >= 1) { "FormSchema version must be >= 1 (got $version)" } | |
| 40 | + } | |
| 41 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/PluginEndpoint.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.http | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Marks a class as a plug-in HTTP controller. | |
| 5 | + * | |
| 6 | + * Why this is a vibe_erp-owned annotation rather than `@RestController`: | |
| 7 | + * - api.v1 must not leak Spring types to plug-ins (CLAUDE.md guardrail #10). | |
| 8 | + * A plug-in that imports `org.springframework.web.bind.annotation.RestController` | |
| 9 | + * is grade-D and rejected at install time. | |
| 10 | + * - The host scans for `@PluginEndpoint` inside each plug-in's child Spring | |
| 11 | + * context and mounts the controller automatically under the prefix | |
| 12 | + * `/api/v1/plugins/<plugin-id>` + [basePath]. The plug-in author never | |
| 13 | + * chooses the absolute URL — that prevents two plug-ins from colliding on | |
| 14 | + * the same path and gives the OpenAPI generator one source of truth. | |
| 15 | + * - The host applies authentication, tenant resolution, locale resolution, | |
| 16 | + * request id propagation, structured logging, and the `RequestContext` | |
| 17 | + * binding to every method on a `@PluginEndpoint` class, so plug-ins | |
| 18 | + * inherit all of that without any per-controller setup. | |
| 19 | + * | |
| 20 | + * The actual HTTP method bindings (GET/POST/etc.), request body parsing, and | |
| 21 | + * response serialization are still expressed using a small set of api.v1 | |
| 22 | + * annotations to be added in subsequent releases. Until then, the host | |
| 23 | + * delegates to standard JAX-RS / Spring Web binding inside the platform | |
| 24 | + * layer; the annotation surface visible to plug-ins is intentionally minimal | |
| 25 | + * in v1.0 so it can grow deliberately. | |
| 26 | + * | |
| 27 | + * ``` | |
| 28 | + * @PluginEndpoint(basePath = "/plates") | |
| 29 | + * class PlateController(private val ctx: RequestContext) { | |
| 30 | + * // mounted at /api/v1/plugins/printingshop/plates | |
| 31 | + * } | |
| 32 | + * ``` | |
| 33 | + */ | |
| 34 | +@Target(AnnotationTarget.CLASS) | |
| 35 | +@Retention(AnnotationRetention.RUNTIME) | |
| 36 | +@MustBeDocumented | |
| 37 | +annotation class PluginEndpoint( | |
| 38 | + /** | |
| 39 | + * Path under the plug-in's namespace, beginning with `/`. The full URL | |
| 40 | + * is `/api/v1/plugins/<plugin-id><basePath>`. | |
| 41 | + */ | |
| 42 | + val basePath: String, | |
| 43 | +) | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.http | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.TenantId | |
| 4 | +import org.vibeerp.api.v1.security.Principal | |
| 5 | +import java.util.Locale | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Per-request context, available by injection inside any class annotated with | |
| 9 | + * [PluginEndpoint]. | |
| 10 | + * | |
| 11 | + * Why an explicit context object instead of "scattered ThreadLocals": | |
| 12 | + * - Plug-ins live in their own classloader, so a static `ThreadLocal` lookup | |
| 13 | + * inside api.v1 would have to bridge classloaders to find the host's | |
| 14 | + * state. An injected context is cleaner, testable, and forces the | |
| 15 | + * plug-in author to declare a dependency on the values they use. | |
| 16 | + * - It is the single answer to "who, where, when, which language, which | |
| 17 | + * request" that every other api.v1 surface (permission check, translator, | |
| 18 | + * repository, event bus) consults under the covers. | |
| 19 | + * | |
| 20 | + * The host provides one [RequestContext] per HTTP request and per workflow | |
| 21 | + * task execution. Background jobs receive a context whose [principal] is a | |
| 22 | + * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is | |
| 23 | + * the job execution id. | |
| 24 | + */ | |
| 25 | +interface RequestContext { | |
| 26 | + | |
| 27 | + /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */ | |
| 28 | + fun principal(): Principal | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * The tenant the request is operating in. Always equal to | |
| 32 | + * `principal().tenantId` in normal flows, but separated for clarity in | |
| 33 | + * "act as" / impersonation scenarios where the two could legitimately | |
| 34 | + * differ. | |
| 35 | + */ | |
| 36 | + fun tenantId(): TenantId | |
| 37 | + | |
| 38 | + /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */ | |
| 39 | + fun locale(): Locale | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Correlation id for this request, propagated to logs, downstream calls, | |
| 43 | + * and emitted events. Stable across the whole request lifecycle. | |
| 44 | + */ | |
| 45 | + fun requestId(): String | |
| 46 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/LocaleProvider.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.i18n | |
| 2 | + | |
| 3 | +import java.util.Locale | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Resolves the locale of the current operation. | |
| 7 | + * | |
| 8 | + * Why a separate interface from [Translator]: | |
| 9 | + * - The decision "what locale am I in right now?" is the host's | |
| 10 | + * responsibility, not the plug-in's. The host walks a precedence chain | |
| 11 | + * (HTTP `Accept-Language` → user preference → tenant default → system | |
| 12 | + * default) on every request and on every background job, and a plug-in | |
| 13 | + * must NEVER reach into HTTP headers itself — it would break for | |
| 14 | + * non-HTTP callers (workflow tasks, scheduled jobs, MCP agents). | |
| 15 | + * - Splitting it from [Translator] keeps the translator pure and testable | |
| 16 | + * (you can pass any [Locale] to `translate`), while [LocaleProvider] is | |
| 17 | + * the impure side that knows about the ambient request. | |
| 18 | + * | |
| 19 | + * Outside of an HTTP request (background jobs, workflow tasks, system | |
| 20 | + * principals), the host returns the tenant's default locale, then the | |
| 21 | + * platform default. There is always *some* locale; this method never throws. | |
| 22 | + */ | |
| 23 | +interface LocaleProvider { | |
| 24 | + | |
| 25 | + /** The locale to use for the current operation. Never `null`. */ | |
| 26 | + fun current(): Locale | |
| 27 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/MessageKey.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.i18n | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Type-safe wrapper around an i18n message key. | |
| 5 | + * | |
| 6 | + * Why a value class instead of "just a String": | |
| 7 | + * - Translator method signatures take `MessageKey`, not `String`, so the | |
| 8 | + * compiler refuses to pass a raw user-facing English sentence into the | |
| 9 | + * translator. That single rule eliminates the most common i18n bug: | |
| 10 | + * "we forgot to translate this one place". | |
| 11 | + * - Zero runtime overhead — `@JvmInline` erases to a `String` at runtime. | |
| 12 | + * | |
| 13 | + * Naming convention is `<plugin-or-pbc>.<area>.<message>`. Examples: | |
| 14 | + * - `identity.user.created` | |
| 15 | + * - `catalog.item.duplicate_sku` | |
| 16 | + * - `printingshop.plate.approval_required` | |
| 17 | + * | |
| 18 | + * The convention is enforced by [init] because once a key is shipped to a | |
| 19 | + * tenant's translation overrides table, renaming it costs everyone money. | |
| 20 | + */ | |
| 21 | +@JvmInline | |
| 22 | +value class MessageKey(val key: String) { | |
| 23 | + init { | |
| 24 | + require(key.isNotBlank()) { "MessageKey must not be blank" } | |
| 25 | + require(KEY_REGEX.matches(key)) { | |
| 26 | + "MessageKey must look like '<area>.<resource>.<message>' (got '$key')" | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + override fun toString(): String = key | |
| 31 | + | |
| 32 | + companion object { | |
| 33 | + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$") | |
| 34 | + } | |
| 35 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/Translator.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.i18n | |
| 2 | + | |
| 3 | +import java.util.Locale | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Plug-in-facing translation lookup. | |
| 7 | + * | |
| 8 | + * Why this is a single small interface and not a fluent builder: | |
| 9 | + * - i18n is a guardrail (CLAUDE.md #6). The interface a plug-in author sees | |
| 10 | + * must be tiny and impossible to use wrong; otherwise plug-ins will hard- | |
| 11 | + * code English strings "just for now" and never come back. | |
| 12 | + * - The host implements this with ICU4J `MessageFormat` so plurals, gender, | |
| 13 | + * number/date/currency formatting all work without the plug-in knowing. | |
| 14 | + * - The lookup goes through the metadata translation overrides table first, | |
| 15 | + * then the plug-in's own bundles, then the platform's bundles, then the | |
| 16 | + * fallback locale, then the key itself. None of that is the plug-in's | |
| 17 | + * problem. | |
| 18 | + * | |
| 19 | + * ``` | |
| 20 | + * translator.translate( | |
| 21 | + * MessageKey("printingshop.plate.approved"), | |
| 22 | + * localeProvider.current(), | |
| 23 | + * mapOf("plateId" to plate.id, "user" to user.username), | |
| 24 | + * ) | |
| 25 | + * ``` | |
| 26 | + */ | |
| 27 | +interface Translator { | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * Resolve a message in the given locale, substituting the named arguments. | |
| 31 | + * | |
| 32 | + * @param key the message key to look up. | |
| 33 | + * @param locale the locale to render in. Use [LocaleProvider.current] to | |
| 34 | + * pick up the request locale automatically. | |
| 35 | + * @param args named arguments referenced from the message text using ICU | |
| 36 | + * MessageFormat placeholders, e.g. `{user}`, `{count, plural, ...}`. | |
| 37 | + * @return the translated string. NEVER `null` — when nothing matches, the | |
| 38 | + * host returns the key itself so the failure is visible in the UI rather | |
| 39 | + * than silently swallowed. | |
| 40 | + */ | |
| 41 | + fun translate(key: MessageKey, locale: Locale, args: Map<String, Any> = emptyMap()): String | |
| 42 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/package-info.kt
0 → 100644
| 1 | +/** | |
| 2 | + * vibe_erp Public Plug-in API v1. | |
| 3 | + * | |
| 4 | + * THE STABILITY CONTRACT | |
| 5 | + * ---------------------- | |
| 6 | + * Everything declared under [org.vibeerp.api.v1] is the public, semver-governed | |
| 7 | + * surface of the vibe_erp framework. Plug-in authors depend on this package and | |
| 8 | + * only this package. | |
| 9 | + * | |
| 10 | + * Within the 1.x release line: | |
| 11 | + * • Adding new types is allowed. | |
| 12 | + * • Adding new methods to existing interfaces is allowed ONLY when a default | |
| 13 | + * implementation is provided so existing plug-ins continue to compile and load. | |
| 14 | + * • Renaming, removing, or changing the observable behavior of any symbol is | |
| 15 | + * a breaking change and requires a major-version bump (api.v2). | |
| 16 | + * | |
| 17 | + * Across a major version, the previous major (api.v1) ships side-by-side with | |
| 18 | + * the new major (api.v2) for at least one major release window so plug-in | |
| 19 | + * authors have a real migration path. | |
| 20 | + * | |
| 21 | + * WHAT IS NOT IN HERE | |
| 22 | + * ------------------- | |
| 23 | + * Anything not in [org.vibeerp.api.v1] — including everything in | |
| 24 | + * `org.vibeerp.platform.*` and `org.vibeerp.pbc.*` — is INTERNAL. Plug-ins | |
| 25 | + * that import internal classes are graded "D" (unsupported) and are rejected | |
| 26 | + * by the plug-in linter at install time. | |
| 27 | + * | |
| 28 | + * See `docs/superpowers/specs/2026-04-07-vibe-erp-architecture-design.md` | |
| 29 | + * sections 4 and 6 for the full extensibility model and api.v1 surface plan. | |
| 30 | + */ | |
| 31 | +package org.vibeerp.api.v1 | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Page.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One page of results returned from [Repository.findAll]. | |
| 5 | + * | |
| 6 | + * Why a dedicated type rather than `Pair<List<T>, Long>`: | |
| 7 | + * - Named fields make the wire format and the AI-agent function catalog | |
| 8 | + * self-describing. "What does the second element of the pair mean?" is | |
| 9 | + * the kind of question that makes plug-in authors miserable. | |
| 10 | + * - Adding fields later (e.g. a continuation token for keyset pagination) | |
| 11 | + * is non-breaking. Adding a third tuple element is. | |
| 12 | + * | |
| 13 | + * `totalElements` is intentionally `Long` because some PBCs (audit log, | |
| 14 | + * inventory movements) routinely cross the 32-bit row count. | |
| 15 | + */ | |
| 16 | +data class Page<T>( | |
| 17 | + val content: List<T>, | |
| 18 | + val pageNumber: Int, | |
| 19 | + val pageSize: Int, | |
| 20 | + val totalElements: Long, | |
| 21 | +) { | |
| 22 | + init { | |
| 23 | + require(pageNumber >= 0) { "Page pageNumber must be >= 0 (got $pageNumber)" } | |
| 24 | + require(pageSize >= 1) { "Page pageSize must be >= 1 (got $pageSize)" } | |
| 25 | + require(totalElements >= 0) { "Page totalElements must be >= 0 (got $totalElements)" } | |
| 26 | + } | |
| 27 | + | |
| 28 | + /** Number of pages, given [pageSize] and [totalElements]. */ | |
| 29 | + val totalPages: Int | |
| 30 | + get() = if (totalElements == 0L) 0 else (((totalElements - 1) / pageSize) + 1).toInt() | |
| 31 | + | |
| 32 | + /** Whether at least one more page exists after this one. */ | |
| 33 | + val hasNext: Boolean | |
| 34 | + get() = (pageNumber + 1) < totalPages | |
| 35 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Query.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Declarative query model used by [Repository.findAll]. | |
| 5 | + * | |
| 6 | + * Why a structured query object instead of "Spring Data Specification" or a | |
| 7 | + * raw string DSL: | |
| 8 | + * - The whole api.v1 surface is forbidden from leaking Spring types | |
| 9 | + * (CLAUDE.md guardrail #10). Spring Data's `Specification` is out. | |
| 10 | + * - A typed model is what makes the OpenAPI generator and the AI agent | |
| 11 | + * function catalog possible: every supported filter shape is enumerable. | |
| 12 | + * - It can be serialized and shipped over the wire, which is how the | |
| 13 | + * cross-process query path will work when the framework eventually grows | |
| 14 | + * a remote-PBC mode. | |
| 15 | + * | |
| 16 | + * The model is intentionally small — equality, ordering, set membership, a | |
| 17 | + * SQL `LIKE`, null checks, and boolean composition. Anything more exotic | |
| 18 | + * (full-text search, geo, vector) belongs in a dedicated capability behind a | |
| 19 | + * separate api.v1 interface, not bolted onto the generic repository. | |
| 20 | + */ | |
| 21 | +data class Query( | |
| 22 | + val filters: List<Filter> = emptyList(), | |
| 23 | + val sort: List<SortClause> = emptyList(), | |
| 24 | + val pageRequest: PageRequest = PageRequest.FIRST, | |
| 25 | +) | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * The closed set of supported filter shapes. | |
| 29 | + * | |
| 30 | + * Sealed so the platform can pattern-match exhaustively when translating to | |
| 31 | + * SQL, and so adding a new shape is a deliberate api.v1 change. | |
| 32 | + */ | |
| 33 | +sealed interface Filter { | |
| 34 | + | |
| 35 | + /** Field equals value. `value` may be `null` to match SQL `IS NULL`. */ | |
| 36 | + data class Eq(val field: String, val value: Any?) : Filter | |
| 37 | + | |
| 38 | + /** Field does not equal value. */ | |
| 39 | + data class Ne(val field: String, val value: Any?) : Filter | |
| 40 | + | |
| 41 | + /** Field strictly less than value. */ | |
| 42 | + data class Lt(val field: String, val value: Any) : Filter | |
| 43 | + | |
| 44 | + /** Field strictly greater than value. */ | |
| 45 | + data class Gt(val field: String, val value: Any) : Filter | |
| 46 | + | |
| 47 | + /** Field value is one of [values]. Empty list matches nothing. */ | |
| 48 | + data class In(val field: String, val values: List<Any>) : Filter | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * SQL `LIKE` against [pattern]. The pattern uses SQL wildcards (`%`, `_`) | |
| 52 | + * unescaped — callers are responsible for escaping user input. | |
| 53 | + */ | |
| 54 | + data class Like(val field: String, val pattern: String) : Filter | |
| 55 | + | |
| 56 | + /** Field is `NULL`. */ | |
| 57 | + data class IsNull(val field: String) : Filter | |
| 58 | + | |
| 59 | + /** Logical conjunction. Empty list is treated as "always true". */ | |
| 60 | + data class And(val operands: List<Filter>) : Filter | |
| 61 | + | |
| 62 | + /** Logical disjunction. Empty list is treated as "always false". */ | |
| 63 | + data class Or(val operands: List<Filter>) : Filter | |
| 64 | + | |
| 65 | + /** Logical negation. */ | |
| 66 | + data class Not(val operand: Filter) : Filter | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Sort directive. Stable across pages — the platform will append a tie-break | |
| 71 | + * on the primary key so pagination is deterministic. | |
| 72 | + */ | |
| 73 | +data class SortClause(val field: String, val direction: SortDirection = SortDirection.ASC) | |
| 74 | + | |
| 75 | +/** Sort direction. Two options, no `RANDOM` — randomness is a separate concern. */ | |
| 76 | +enum class SortDirection { ASC, DESC } | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * Page coordinates. Zero-indexed for consistency with the rest of the JVM | |
| 80 | + * ecosystem (Spring Data, Hibernate). Maximum page size is enforced by the | |
| 81 | + * platform, not here, because the limit may differ per deployment. | |
| 82 | + */ | |
| 83 | +data class PageRequest(val page: Int, val size: Int) { | |
| 84 | + init { | |
| 85 | + require(page >= 0) { "PageRequest page must be >= 0 (got $page)" } | |
| 86 | + require(size in 1..10_000) { "PageRequest size must be 1..10000 (got $size)" } | |
| 87 | + } | |
| 88 | + | |
| 89 | + companion object { | |
| 90 | + val FIRST = PageRequest(page = 0, size = 50) | |
| 91 | + } | |
| 92 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | +import org.vibeerp.api.v1.entity.Entity | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Generic, tenant-scoped repository contract for any [Entity]. | |
| 8 | + * | |
| 9 | + * Why this lives in api.v1: | |
| 10 | + * - Plug-ins must be able to persist their own entities without depending on | |
| 11 | + * Spring Data, JPA, or any platform internal class. The platform binds this | |
| 12 | + * interface to a Hibernate-backed implementation per entity at startup. | |
| 13 | + * - It is the cleanest place to enforce the tenant guarantee: **none of these | |
| 14 | + * methods accept a `tenantId`**. The tenant is resolved from the ambient | |
| 15 | + * [org.vibeerp.api.v1.http.RequestContext] (or, in background jobs, from the | |
| 16 | + * job's tenant binding) by the platform. A plug-in cannot read or write | |
| 17 | + * cross-tenant data through this contract — that is the whole point. | |
| 18 | + * - It is generic on `T : Entity` so the type system rejects "wrong-entity" | |
| 19 | + * mistakes at compile time. | |
| 20 | + * | |
| 21 | + * Methods are deliberately few. Anything more sophisticated (joins, | |
| 22 | + * projections, native SQL) is a smell at this layer — model the use case as a | |
| 23 | + * domain service or a query object instead. Adding methods later (with default | |
| 24 | + * implementations) is non-breaking; removing them is not. | |
| 25 | + */ | |
| 26 | +interface Repository<T : Entity> { | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * Find by primary key. Returns `null` when no row exists for the current | |
| 30 | + * tenant — even if a row with that id exists for *another* tenant. Cross- | |
| 31 | + * tenant reads are unobservable through this API. | |
| 32 | + */ | |
| 33 | + fun findById(id: Id<T>): T? | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * Run a query and return one page of results. The page descriptor is part | |
| 37 | + * of [query], not a separate parameter, so it travels with the rest of the | |
| 38 | + * query intact (important for paginated AI-agent function calls). | |
| 39 | + */ | |
| 40 | + fun findAll(query: Query): Page<T> | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * Insert or update. Returning the saved entity (rather than `Unit`) gives | |
| 44 | + * the platform a place to populate generated columns (audit timestamps, | |
| 45 | + * generated ids) before the plug-in sees them again. | |
| 46 | + */ | |
| 47 | + fun save(entity: T): T | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Delete by id. Returns `true` if a row was deleted, `false` if no | |
| 51 | + * matching row existed for the current tenant. | |
| 52 | + */ | |
| 53 | + fun delete(id: Id<T>): Boolean | |
| 54 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Transaction.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Plug-in-facing transaction boundary. | |
| 5 | + * | |
| 6 | + * Why this exists instead of "use Spring `@Transactional`": | |
| 7 | + * - api.v1 is forbidden from exposing Spring types (CLAUDE.md guardrail #10). | |
| 8 | + * A plug-in that imports `org.springframework.transaction.annotation.Transactional` | |
| 9 | + * is grade-D and rejected at install time. | |
| 10 | + * - The host needs a single, controlled place to enforce nested-transaction | |
| 11 | + * rules, tenant binding, and outbox flushing across plug-in boundaries. | |
| 12 | + * A method-level annotation cannot do those things from a child Spring | |
| 13 | + * context cleanly. | |
| 14 | + * - It is small enough to be implemented over JTA, Spring TX, or a future | |
| 15 | + * Kotlin coroutines transactional context without an api.v1 change. | |
| 16 | + * | |
| 17 | + * Semantics: | |
| 18 | + * - The block runs in a single platform transaction. If [block] throws, the | |
| 19 | + * transaction rolls back and the exception propagates unchanged. | |
| 20 | + * - Nested calls join the surrounding transaction; they do not start a new | |
| 21 | + * one. Plug-ins that need savepoints must request them through a future | |
| 22 | + * api.v1 extension, not by stacking calls. | |
| 23 | + * - The current tenant and principal are propagated automatically. | |
| 24 | + * | |
| 25 | + * ``` | |
| 26 | + * pluginContext.transaction.inTransaction { | |
| 27 | + * val saved = repo.save(plate) | |
| 28 | + * eventBus.publish(PlateApproved(saved.id)) | |
| 29 | + * saved | |
| 30 | + * } | |
| 31 | + * ``` | |
| 32 | + */ | |
| 33 | +interface Transaction { | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * Run [block] inside a transaction and return its result. | |
| 37 | + * | |
| 38 | + * @param T the type returned by the block. Use `Unit` for side-effect-only | |
| 39 | + * blocks; the host treats `Unit` no differently from any other return | |
| 40 | + * type. | |
| 41 | + */ | |
| 42 | + fun <T> inTransaction(block: () -> T): T | |
| 43 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/ExtensionPoint.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.plugin | |
| 2 | + | |
| 3 | +import kotlin.reflect.KClass | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Marks an interface or abstract class as an *extension point*: a seam the | |
| 7 | + * core declares so that plug-ins (and other PBCs) can supply implementations. | |
| 8 | + * | |
| 9 | + * Why annotated rather than enforced by base-class inheritance: | |
| 10 | + * - Several extension points are plain interfaces (e.g. a custom field type | |
| 11 | + * handler, a workflow task handler, a report data provider) and forcing | |
| 12 | + * them to extend a marker class would be noise. An annotation lets the | |
| 13 | + * plug-in linter and the OpenAPI generator find them by metadata alone. | |
| 14 | + * - It documents intent at the declaration site: a reader sees | |
| 15 | + * `@ExtensionPoint` and knows "plug-ins are expected to implement this". | |
| 16 | + * - The platform's plug-in loader scans for `@Extension` (below) and | |
| 17 | + * cross-references it to `@ExtensionPoint`-annotated types so a typo | |
| 18 | + * fails at install time, not at first use. | |
| 19 | + */ | |
| 20 | +@Target(AnnotationTarget.CLASS) | |
| 21 | +@Retention(AnnotationRetention.RUNTIME) | |
| 22 | +@MustBeDocumented | |
| 23 | +annotation class ExtensionPoint | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * Marks a class as a plug-in's implementation of an [ExtensionPoint]. | |
| 27 | + * | |
| 28 | + * The plug-in loader instantiates every `@Extension`-annotated class in the | |
| 29 | + * plug-in's child Spring context, verifies that [point] is itself | |
| 30 | + * `@ExtensionPoint`-annotated, and registers the instance with the matching | |
| 31 | + * platform registry. | |
| 32 | + * | |
| 33 | + * ``` | |
| 34 | + * @Extension(point = TaskHandler::class) | |
| 35 | + * class ApprovePlateHandler : TaskHandler { ... } | |
| 36 | + * ``` | |
| 37 | + */ | |
| 38 | +@Target(AnnotationTarget.CLASS) | |
| 39 | +@Retention(AnnotationRetention.RUNTIME) | |
| 40 | +@MustBeDocumented | |
| 41 | +annotation class Extension( | |
| 42 | + /** The extension-point type this class implements. Must be `@ExtensionPoint`-annotated. */ | |
| 43 | + val point: KClass<*>, | |
| 44 | +) | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/Plugin.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.plugin | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The single entry-class contract every PF4J plug-in implements. | |
| 5 | + * | |
| 6 | + * Why this is in api.v1: | |
| 7 | + * - PF4J's own `Plugin` base class is an internal implementation choice of | |
| 8 | + * the platform-plugins module. Exposing it would tie every plug-in to a | |
| 9 | + * specific PF4J version forever — the textbook reason api.v1 exists. | |
| 10 | + * - The four methods are the bare minimum to manage a plug-in's lifecycle: | |
| 11 | + * identify itself, declare its version, start, and stop. Anything richer | |
| 12 | + * (hot reload, partial reload, health check) is added as default methods | |
| 13 | + * in later 1.x releases without breaking existing plug-ins. | |
| 14 | + * | |
| 15 | + * Lifecycle (architecture spec section 7): | |
| 16 | + * 1. The platform reads the plug-in's `plugin.yml` ([PluginManifest]). | |
| 17 | + * 2. The classloader is created. | |
| 18 | + * 3. The plug-in's entry class (which implements this interface) is | |
| 19 | + * instantiated. | |
| 20 | + * 4. [start] is called once with the [PluginContext]. The plug-in registers | |
| 21 | + * its endpoints, listeners, workflow handlers, etc. on the context. | |
| 22 | + * 5. When the plug-in is disabled or the host shuts down, [stop] is called | |
| 23 | + * once. The plug-in releases any resources it holds. The platform tears | |
| 24 | + * down the child Spring context, the listeners, and the classloader. | |
| 25 | + * | |
| 26 | + * Implementations must be safe to call [stop] without [start] having | |
| 27 | + * succeeded — the platform may invoke it after a partial start failure. | |
| 28 | + */ | |
| 29 | +interface Plugin { | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Stable plug-in identifier matching `id` in `plugin.yml`. Used as the | |
| 33 | + * key for tenant scoping, table prefixes (`plugin_<id>__*`), permission | |
| 34 | + * scoping, and the URL prefix `/api/v1/plugins/<id>`. Must match the id | |
| 35 | + * in [PluginManifest] — the platform verifies this at start time. | |
| 36 | + */ | |
| 37 | + fun id(): String | |
| 38 | + | |
| 39 | + /** Semver version of this plug-in build. Reported in admin UIs and audit logs. */ | |
| 40 | + fun version(): String | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * Called once when the plug-in is being started. The [context] gives | |
| 44 | + * access to every host service the plug-in is allowed to use. | |
| 45 | + * | |
| 46 | + * Throwing from [start] aborts the plug-in load. The platform calls | |
| 47 | + * [stop] afterwards regardless. | |
| 48 | + */ | |
| 49 | + fun start(context: PluginContext) | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * Called once when the plug-in is being stopped. Implementations should | |
| 53 | + * release resources owned by the plug-in itself; listeners, endpoints, | |
| 54 | + * and Spring beans registered through [PluginContext] are torn down by | |
| 55 | + * the platform automatically. | |
| 56 | + */ | |
| 57 | + fun stop() | |
| 58 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.plugin | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.entity.EntityRegistry | |
| 4 | +import org.vibeerp.api.v1.event.EventBus | |
| 5 | +import org.vibeerp.api.v1.i18n.LocaleProvider | |
| 6 | +import org.vibeerp.api.v1.i18n.Translator | |
| 7 | +import org.vibeerp.api.v1.persistence.Transaction | |
| 8 | +import org.vibeerp.api.v1.security.PermissionCheck | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * The bag of host services a plug-in receives at [Plugin.start]. | |
| 12 | + * | |
| 13 | + * Why a single context object instead of constructor injection of each | |
| 14 | + * service: | |
| 15 | + * - Plug-ins are instantiated by PF4J before any Spring wiring exists, so | |
| 16 | + * constructor injection in the Spring sense is not yet available at | |
| 17 | + * [Plugin.start] time. Handing a context object solves the bootstrapping | |
| 18 | + * problem in one line. | |
| 19 | + * - It is forward-compatible: adding a new host service is a non-breaking | |
| 20 | + * addition to this interface (with a default implementation that throws | |
| 21 | + * `UnsupportedOperationException` — see the per-getter doc). | |
| 22 | + * | |
| 23 | + * The plug-in is free to keep references to whichever services it needs and | |
| 24 | + * pass them into its own Spring beans inside the child context. Holding onto | |
| 25 | + * the entire context is also fine. | |
| 26 | + */ | |
| 27 | +interface PluginContext { | |
| 28 | + | |
| 29 | + /** Publish/subscribe to domain events. Backed by the platform bus + outbox. */ | |
| 30 | + val eventBus: EventBus | |
| 31 | + | |
| 32 | + /** Run blocks in a platform transaction without ever importing Spring. */ | |
| 33 | + val transaction: Transaction | |
| 34 | + | |
| 35 | + /** Resolve translations using the host's ICU MessageFormat machinery. */ | |
| 36 | + val translator: Translator | |
| 37 | + | |
| 38 | + /** Read the current request/job locale. */ | |
| 39 | + val localeProvider: LocaleProvider | |
| 40 | + | |
| 41 | + /** Soft and hard permission checks. */ | |
| 42 | + val permissionCheck: PermissionCheck | |
| 43 | + | |
| 44 | + /** Look up entity descriptors (core PBC, plug-in, or user-defined). */ | |
| 45 | + val entityRegistry: EntityRegistry | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger` | |
| 49 | + * because the platform tags every log line with the plug-in id, the | |
| 50 | + * tenant, the request id, and the principal automatically. A plug-in that | |
| 51 | + * brings its own SLF4J binding bypasses all of that and is hard to debug | |
| 52 | + * in a multi-plug-in deployment. | |
| 53 | + */ | |
| 54 | + val logger: PluginLogger | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * Minimal logger interface exposed to plug-ins. | |
| 59 | + * | |
| 60 | + * Why not just expose SLF4J: | |
| 61 | + * - api.v1 forbids leaking external library types (CLAUDE.md guardrail #10). | |
| 62 | + * SLF4J version skew across plug-in classloaders is a real PF4J pain that | |
| 63 | + * a thin abstraction sidesteps entirely. | |
| 64 | + * - The host implementation forwards to whichever logging backend is | |
| 65 | + * configured at the platform layer (Logback today; structured JSON in the | |
| 66 | + * cloud build), without the plug-in choosing. | |
| 67 | + * | |
| 68 | + * Three levels are intentional. `debug` and `trace` are deliberately omitted | |
| 69 | + * from the contract because they encourage chatty plug-ins; if a plug-in | |
| 70 | + * needs verbose diagnostics it should ship its own logger inside the plug-in | |
| 71 | + * jar (which is fine — it just won't get the platform's structured tags). | |
| 72 | + */ | |
| 73 | +interface PluginLogger { | |
| 74 | + /** Informational message about normal operation. */ | |
| 75 | + fun info(message: String) | |
| 76 | + | |
| 77 | + /** Something looked wrong but the operation continued. */ | |
| 78 | + fun warn(message: String, error: Throwable? = null) | |
| 79 | + | |
| 80 | + /** An operation failed. */ | |
| 81 | + fun error(message: String, error: Throwable? = null) | |
| 82 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginManifest.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.plugin | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Strongly-typed view of a plug-in's `plugin.yml` manifest. | |
| 5 | + * | |
| 6 | + * Why this is in api.v1 even though plug-ins do not parse it themselves: | |
| 7 | + * - The host parses `plugin.yml` and offers the result to the plug-in via | |
| 8 | + * [PluginContext] (and to admin UIs, the linter, and the marketplace). | |
| 9 | + * Putting the type in api.v1 means every consumer agrees on the schema. | |
| 10 | + * - The schema is part of the public contract: changing a field name in | |
| 11 | + * `plugin.yml` would break every existing plug-in's manifest. Documenting | |
| 12 | + * it as a Kotlin data class is the cheapest way to keep that promise. | |
| 13 | + * | |
| 14 | + * The corresponding YAML shape: | |
| 15 | + * | |
| 16 | + * ``` | |
| 17 | + * id: printingshop | |
| 18 | + * version: 1.4.2 | |
| 19 | + * requiresApi: "1.x" | |
| 20 | + * name: | |
| 21 | + * en-US: Printing Shop | |
| 22 | + * zh-CN: 印刷工坊 | |
| 23 | + * dependencies: | |
| 24 | + * - identity | |
| 25 | + * - catalog | |
| 26 | + * permissions: | |
| 27 | + * - printingshop.plate.create | |
| 28 | + * - printingshop.plate.approve | |
| 29 | + * metadata: | |
| 30 | + * - metadata/forms/plate.json | |
| 31 | + * - metadata/workflows/approval.bpmn | |
| 32 | + * ``` | |
| 33 | + * | |
| 34 | + * @property id Stable plug-in id, used everywhere ([Plugin.id], URL prefix, | |
| 35 | + * table prefix, metadata `source` tag). | |
| 36 | + * @property version Plug-in build version. Semver, but the framework does | |
| 37 | + * not enforce it parses — some customers ship date-stamped builds. | |
| 38 | + * @property requiresApi Required api.v1 major version, expressed as a range. | |
| 39 | + * The platform refuses to load a plug-in whose required range does not | |
| 40 | + * include the running host's api version. Today the only supported value | |
| 41 | + * is `"1.x"`; this field exists so the same parser handles `"2.x"` etc. | |
| 42 | + * without an api break. | |
| 43 | + * @property name Locale → display name. Locale codes are BCP-47. | |
| 44 | + * @property dependencies Other plug-in ids that must be present and started | |
| 45 | + * before this plug-in starts. The platform topologically orders startup; | |
| 46 | + * cycles are rejected at install. | |
| 47 | + * @property permissions Keys of the permissions this plug-in declares (see | |
| 48 | + * [org.vibeerp.api.v1.security.Permission]). The platform refuses to start | |
| 49 | + * a plug-in that uses a permission not in this list. | |
| 50 | + * @property metadata JAR-relative paths the platform should load at startup | |
| 51 | + * to seed forms, workflows, rules, list views, and translations. | |
| 52 | + */ | |
| 53 | +data class PluginManifest( | |
| 54 | + val id: String, | |
| 55 | + val version: String, | |
| 56 | + val requiresApi: String, | |
| 57 | + val name: Map<String, String> = emptyMap(), | |
| 58 | + val dependencies: List<String> = emptyList(), | |
| 59 | + val permissions: List<String> = emptyList(), | |
| 60 | + val metadata: List<String> = emptyList(), | |
| 61 | +) { | |
| 62 | + init { | |
| 63 | + require(id.isNotBlank()) { "PluginManifest id must not be blank" } | |
| 64 | + require(id.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { | |
| 65 | + "PluginManifest id may only contain letters, digits, '-', or '_' (got '$id')" | |
| 66 | + } | |
| 67 | + require(version.isNotBlank()) { "PluginManifest version must not be blank" } | |
| 68 | + require(requiresApi.isNotBlank()) { "PluginManifest requiresApi must not be blank" } | |
| 69 | + } | |
| 70 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Permission.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.security | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A named permission a plug-in or PBC declares. | |
| 5 | + * | |
| 6 | + * Why permissions are first-class data, not magic strings scattered through | |
| 7 | + * code: | |
| 8 | + * - The role editor in the web UI lists every registered permission so an | |
| 9 | + * administrator can grant it. There is no way to do that if permissions | |
| 10 | + * are unenumerable. | |
| 11 | + * - The OpenAPI generator and the AI-agent function catalog annotate every | |
| 12 | + * operation with the permissions it requires; an LLM that calls the API | |
| 13 | + * can therefore refuse an action up-front rather than discovering the | |
| 14 | + * failure mid-call. | |
| 15 | + * - Plug-ins register their permissions at startup, so uninstalling a | |
| 16 | + * plug-in cleanly removes its permission rows from `metadata__permission`. | |
| 17 | + * | |
| 18 | + * Naming convention is `<plugin-or-pbc>.<resource>.<action>`. Examples: | |
| 19 | + * - `identity.user.create` | |
| 20 | + * - `catalog.item.update` | |
| 21 | + * - `printingshop.plate.approve` (declared by the reference plug-in) | |
| 22 | + * | |
| 23 | + * The convention is enforced by [init], not by mere documentation, because | |
| 24 | + * "we'll fix the names later" never works in a permission catalog. | |
| 25 | + * | |
| 26 | + * @property key The dotted permission identifier. Lower-case, three or more | |
| 27 | + * segments separated by `.`, each segment `[a-z][a-z0-9_]*`. | |
| 28 | + * @property description Human-readable explanation shown in the role editor. | |
| 29 | + * This is a fallback English string; tenants override it through the i18n | |
| 30 | + * layer. | |
| 31 | + * @property pluginId The plug-in that owns this permission, or `null` if it | |
| 32 | + * belongs to a core PBC. The platform uses this to scope cleanup on | |
| 33 | + * plug-in uninstall. | |
| 34 | + */ | |
| 35 | +data class Permission( | |
| 36 | + val key: String, | |
| 37 | + val description: String, | |
| 38 | + val pluginId: String? = null, | |
| 39 | +) { | |
| 40 | + init { | |
| 41 | + require(description.isNotBlank()) { "Permission description must not be blank" } | |
| 42 | + require(KEY_REGEX.matches(key)) { | |
| 43 | + "Permission key must look like '<area>.<resource>.<action>' (got '$key')" | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + companion object { | |
| 48 | + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$") | |
| 49 | + } | |
| 50 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/PermissionCheck.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.security | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Plug-in-facing permission check. | |
| 5 | + * | |
| 6 | + * Why this is its own interface instead of "throw whenever you feel like it": | |
| 7 | + * - Permission checks must be enforced at the application-service layer | |
| 8 | + * (not just the HTTP layer) so background jobs, workflow tasks, AI-agent | |
| 9 | + * calls, and direct service-to-service calls all funnel through the same | |
| 10 | + * code path. A plug-in that consults this interface is automatically | |
| 11 | + * correct against every transport. | |
| 12 | + * - The two-method shape ([has] for soft checks, [require] for hard checks) | |
| 13 | + * is deliberate. Plug-ins frequently want to *render different UI* based | |
| 14 | + * on whether the user can do a thing, and that case must not throw. | |
| 15 | + * | |
| 16 | + * The platform binds this to the active [Principal] from | |
| 17 | + * [org.vibeerp.api.v1.http.RequestContext] so plug-ins almost never need to | |
| 18 | + * pass a principal explicitly — but the explicit form exists because some | |
| 19 | + * legitimate flows (impersonation, "act as", scheduled jobs queued by user A | |
| 20 | + * but executed by the system) need to ask the question for a *different* | |
| 21 | + * principal than the ambient one. | |
| 22 | + */ | |
| 23 | +interface PermissionCheck { | |
| 24 | + | |
| 25 | + /** | |
| 26 | + * Soft check: returns whether [principal] currently holds [permission]. | |
| 27 | + * Never throws on a denial. | |
| 28 | + */ | |
| 29 | + fun has(principal: Principal, permission: Permission): Boolean | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Hard check: throws [PermissionDeniedException] if [principal] does not | |
| 33 | + * hold [permission]. Returns normally otherwise. Use this at the entry | |
| 34 | + * point of any state-changing operation a plug-in exposes. | |
| 35 | + */ | |
| 36 | + fun require(principal: Principal, permission: Permission) { | |
| 37 | + if (!has(principal, permission)) { | |
| 38 | + throw PermissionDeniedException(principal, permission) | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * Thrown by [PermissionCheck.require] when a principal lacks a permission. | |
| 45 | + * | |
| 46 | + * The two fields ([principal], [permission]) are exposed because the platform's | |
| 47 | + * HTTP error mapper turns this exception into a structured 403 response that | |
| 48 | + * includes the missing permission key — important for debuggability and for | |
| 49 | + * AI agents that need to ask the user to grant access. | |
| 50 | + */ | |
| 51 | +class PermissionDeniedException( | |
| 52 | + val principal: Principal, | |
| 53 | + val permission: Permission, | |
| 54 | +) : RuntimeException("Principal ${principal.id} lacks permission '${permission.key}'") | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.security | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | +import org.vibeerp.api.v1.core.TenantId | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Type alias for a principal identifier. | |
| 8 | + * | |
| 9 | + * Distinct phantom-typed alias of [Id] so signatures like `createdBy: PrincipalId` | |
| 10 | + * are self-documenting in IDE hovers and so the compiler refuses to mix a | |
| 11 | + * `PrincipalId` with, say, an `Id<Order>`. | |
| 12 | + */ | |
| 13 | +typealias PrincipalId = Id<Principal> | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Who is making a request. | |
| 17 | + * | |
| 18 | + * vibe_erp recognizes three kinds of caller, and they all have legitimate | |
| 19 | + * audit, permission, and event-attribution needs: | |
| 20 | + * | |
| 21 | + * - [User]: a human (or an AI agent acting *on behalf of* a human, which is | |
| 22 | + * still a [User] in this model — the agent does not get its own principal | |
| 23 | + * type, which would let it circumvent per-user permissions). | |
| 24 | + * - [System]: the framework itself, e.g. background jobs, migrations, the | |
| 25 | + * metadata seeder. Recorded in audit logs as `system:<name>` so an auditor | |
| 26 | + * can tell automation from a human. | |
| 27 | + * - [PluginPrincipal]: a plug-in acting under its own identity, used for | |
| 28 | + * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes | |
| 29 | + * without a user request (e.g. a periodic sync from an external system). | |
| 30 | + * | |
| 31 | + * Sealed because the framework needs to switch on the full set in audit and | |
| 32 | + * permission code; new principal kinds are deliberate api.v1 changes. | |
| 33 | + */ | |
| 34 | +sealed interface Principal { | |
| 35 | + | |
| 36 | + /** This principal's stable id, used as a foreign key in audit columns. */ | |
| 37 | + val id: PrincipalId | |
| 38 | + | |
| 39 | + /** | |
| 40 | + * The tenant the principal is acting in. A [User] always has one. A | |
| 41 | + * [System] principal uses [TenantId.DEFAULT] for global jobs and a | |
| 42 | + * specific tenant for per-tenant jobs. A [PluginPrincipal] uses the | |
| 43 | + * tenant of the operation it is performing. | |
| 44 | + */ | |
| 45 | + val tenantId: TenantId | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * A real human user (or an AI agent acting on a human's behalf, which the | |
| 49 | + * framework intentionally does not distinguish at the principal level). | |
| 50 | + */ | |
| 51 | + data class User( | |
| 52 | + override val id: PrincipalId, | |
| 53 | + override val tenantId: TenantId, | |
| 54 | + val username: String, | |
| 55 | + ) : Principal { | |
| 56 | + init { | |
| 57 | + require(username.isNotBlank()) { "User username must not be blank" } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * The framework itself. [name] is a short identifier of which subsystem | |
| 63 | + * is acting (e.g. `metadata-seeder`, `outbox-drain`). | |
| 64 | + */ | |
| 65 | + data class System( | |
| 66 | + override val id: PrincipalId, | |
| 67 | + override val tenantId: TenantId, | |
| 68 | + val name: String, | |
| 69 | + ) : Principal { | |
| 70 | + init { | |
| 71 | + require(name.isNotBlank()) { "System name must not be blank" } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + /** A plug-in acting under its own identity. */ | |
| 76 | + data class PluginPrincipal( | |
| 77 | + override val id: PrincipalId, | |
| 78 | + override val tenantId: TenantId, | |
| 79 | + val pluginId: String, | |
| 80 | + ) : Principal { | |
| 81 | + init { | |
| 82 | + require(pluginId.isNotBlank()) { "PluginPrincipal pluginId must not be blank" } | |
| 83 | + } | |
| 84 | + } | |
| 85 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.workflow | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Implementation contract for a single workflow task type. | |
| 5 | + * | |
| 6 | + * Why this is the only workflow seam in v1.0: | |
| 7 | + * - Workflows are data, not code (CLAUDE.md guardrail #2). The shape of a | |
| 8 | + * workflow lives in BPMN; the only thing a plug-in legitimately needs to | |
| 9 | + * write in code is the body of a service task. That is exactly what | |
| 10 | + * [TaskHandler] models. | |
| 11 | + * - It deliberately does not expose the Flowable `DelegateExecution` type, | |
| 12 | + * because doing so would couple every plug-in to a specific Flowable | |
| 13 | + * version forever. The platform adapts Flowable to this api.v1 shape | |
| 14 | + * inside its workflow host module. | |
| 15 | + * | |
| 16 | + * Plug-ins register handlers via the `@org.vibeerp.api.v1.plugin.Extension` | |
| 17 | + * annotation pointing at this interface, and the platform routes incoming | |
| 18 | + * Flowable service-task executions to the handler whose [key] matches. | |
| 19 | + * | |
| 20 | + * ``` | |
| 21 | + * @Extension(point = TaskHandler::class) | |
| 22 | + * class ApprovePlate : TaskHandler { | |
| 23 | + * override fun key() = "printingshop.plate.approve" | |
| 24 | + * override fun execute(task: WorkflowTask, ctx: TaskContext) { | |
| 25 | + * val plateId = task.variables["plateId"] as String | |
| 26 | + * // domain logic … | |
| 27 | + * ctx.set("approved", true) | |
| 28 | + * } | |
| 29 | + * } | |
| 30 | + * ``` | |
| 31 | + */ | |
| 32 | +interface TaskHandler { | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * The BPMN service-task key this handler claims. Convention is | |
| 36 | + * `<plugin-id>.<aggregate>.<verb>`. Two handlers in the same running | |
| 37 | + * deployment must not return the same key — the platform fails plug-in | |
| 38 | + * load on conflict. | |
| 39 | + */ | |
| 40 | + fun key(): String | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * Execute the task. The implementation may write output variables via | |
| 44 | + * [ctx]. Throwing aborts the workflow with a Flowable BPMN error, | |
| 45 | + * which the surrounding process model can catch. | |
| 46 | + */ | |
| 47 | + fun execute(task: WorkflowTask, ctx: TaskContext) | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * Mutable side of a task execution: the only way a [TaskHandler] writes | |
| 52 | + * variables back into the workflow. | |
| 53 | + * | |
| 54 | + * Why a small interface instead of "return a Map": | |
| 55 | + * - The host wants to know which variables a handler set (for audit) and | |
| 56 | + * in what order (for diagnostic logging). A returned map loses both. | |
| 57 | + * - It leaves room to grow methods like `signal(eventName)` and | |
| 58 | + * `complete()` without changing the handler's signature. | |
| 59 | + */ | |
| 60 | +interface TaskContext { | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Set a process variable. The value is persisted by the workflow engine | |
| 64 | + * under the same transaction as the surrounding task execution. | |
| 65 | + * Passing `null` removes the variable. | |
| 66 | + */ | |
| 67 | + fun set(name: String, value: Any?) | |
| 68 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/WorkflowTask.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.workflow | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Description of a workflow task instance the host hands to a [TaskHandler]. | |
| 5 | + * | |
| 6 | + * Why this is in v1.0 even though the full workflow API is v0.2: | |
| 7 | + * - The reference printing-shop plug-in is the executable acceptance test | |
| 8 | + * (CLAUDE.md, "The reference plug-in is the executable acceptance test"), | |
| 9 | + * and that plug-in needs to register at least one task handler against | |
| 10 | + * the embedded Flowable engine. v1.0 ships the smallest possible | |
| 11 | + * contract that lets the plug-in do that. | |
| 12 | + * - The richer surface (signal/timer events, sub-processes, candidate | |
| 13 | + * groups, business calendars) lands in v0.2 of the api.v1 line as | |
| 14 | + * purely additive interfaces. None of it changes the shape below. | |
| 15 | + * | |
| 16 | + * @property taskKey The BPMN element id (`<serviceTask id="...">`) that | |
| 17 | + * triggered this handler. A single handler may be wired to several keys | |
| 18 | + * inside one process; matching is performed by the host based on | |
| 19 | + * [TaskHandler.key] and the BPMN definition. | |
| 20 | + * @property processInstanceId Flowable's process-instance id, opaque to the | |
| 21 | + * plug-in. Useful for log correlation and for callbacks back into the | |
| 22 | + * workflow engine. | |
| 23 | + * @property variables The current process variables visible to this task. | |
| 24 | + * The map is read-only — to write back, the handler uses | |
| 25 | + * [TaskContext.set] which routes through Flowable so the variables are | |
| 26 | + * persisted under the right transaction. | |
| 27 | + */ | |
| 28 | +data class WorkflowTask( | |
| 29 | + val taskKey: String, | |
| 30 | + val processInstanceId: String, | |
| 31 | + val variables: Map<String, Any?> = emptyMap(), | |
| 32 | +) { | |
| 33 | + init { | |
| 34 | + require(taskKey.isNotBlank()) { "WorkflowTask taskKey must not be blank" } | |
| 35 | + require(processInstanceId.isNotBlank()) { "WorkflowTask processInstanceId must not be blank" } | |
| 36 | + } | |
| 37 | +} | ... | ... |
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.isEqualTo | |
| 6 | +import org.junit.jupiter.api.Test | |
| 7 | + | |
| 8 | +class IdentifiersTest { | |
| 9 | + | |
| 10 | + @Test | |
| 11 | + fun `TenantId rejects blank`() { | |
| 12 | + assertFailure { TenantId("") } | |
| 13 | + assertFailure { TenantId(" ") } | |
| 14 | + } | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + fun `TenantId rejects too-long values`() { | |
| 18 | + val tooLong = "a".repeat(65) | |
| 19 | + assertFailure { TenantId(tooLong) } | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + fun `TenantId rejects invalid characters`() { | |
| 24 | + assertFailure { TenantId("acme corp") } // space | |
| 25 | + assertFailure { TenantId("acme.corp") } // dot | |
| 26 | + assertFailure { TenantId("acme/corp") } // slash | |
| 27 | + assertFailure { TenantId("acme!") } // punctuation | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test | |
| 31 | + fun `TenantId accepts the default tenant id`() { | |
| 32 | + assertThat(TenantId("default").value).isEqualTo("default") | |
| 33 | + assertThat(TenantId.DEFAULT.value).isEqualTo("default") | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + fun `TenantId accepts hyphens digits and underscores`() { | |
| 38 | + assertThat(TenantId("tenant-1").value).isEqualTo("tenant-1") | |
| 39 | + assertThat(TenantId("acme_2").value).isEqualTo("acme_2") | |
| 40 | + assertThat(TenantId("ACME").value).isEqualTo("ACME") | |
| 41 | + } | |
| 42 | +} | ... | ... |
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/MoneyTest.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.core | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.isEqualTo | |
| 6 | +import assertk.assertions.messageContains | |
| 7 | +import org.junit.jupiter.api.Test | |
| 8 | +import java.math.BigDecimal | |
| 9 | + | |
| 10 | +class MoneyTest { | |
| 11 | + | |
| 12 | + @Test | |
| 13 | + fun `adding two USD amounts produces a USD amount`() { | |
| 14 | + val a = Money(BigDecimal("10.00"), Currency.USD) | |
| 15 | + val b = Money(BigDecimal("2.50"), Currency.USD) | |
| 16 | + | |
| 17 | + val sum = a + b | |
| 18 | + | |
| 19 | + assertThat(sum).isEqualTo(Money(BigDecimal("12.50"), Currency.USD)) | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + fun `adding USD to EUR throws`() { | |
| 24 | + val usd = Money(BigDecimal("10.00"), Currency.USD) | |
| 25 | + val eur = Money(BigDecimal("10.00"), Currency.EUR) | |
| 26 | + | |
| 27 | + assertFailure { usd + eur }.messageContains("different currencies") | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test | |
| 31 | + fun `JPY rejects fractional amounts because its default fraction digits is zero`() { | |
| 32 | + // JPY has 0 fraction digits — anything with a non-zero scale is wrong. | |
| 33 | + assertFailure { Money(BigDecimal("100.50"), Currency.JPY) } | |
| 34 | + .messageContains("scale") | |
| 35 | + | |
| 36 | + // A whole-yen amount is fine. | |
| 37 | + val whole = Money(BigDecimal("100"), Currency.JPY) | |
| 38 | + assertThat(whole.amount).isEqualTo(BigDecimal("100")) | |
| 39 | + } | |
| 40 | +} | ... | ... |
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/persistence/QueryTest.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | |
| 2 | + | |
| 3 | +import assertk.assertThat | |
| 4 | +import assertk.assertions.isEqualTo | |
| 5 | +import assertk.assertions.isInstanceOf | |
| 6 | +import org.junit.jupiter.api.Test | |
| 7 | + | |
| 8 | +class QueryTest { | |
| 9 | + | |
| 10 | + @Test | |
| 11 | + fun `every Filter shape can be constructed`() { | |
| 12 | + val filters: List<Filter> = listOf( | |
| 13 | + Filter.Eq("name", "Acme"), | |
| 14 | + Filter.Ne("status", "DELETED"), | |
| 15 | + Filter.Lt("price", 100), | |
| 16 | + Filter.Gt("price", 1), | |
| 17 | + Filter.In("category", listOf("a", "b", "c")), | |
| 18 | + Filter.Like("name", "Ac%"), | |
| 19 | + Filter.IsNull("deleted_at"), | |
| 20 | + Filter.And(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))), | |
| 21 | + Filter.Or(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))), | |
| 22 | + Filter.Not(Filter.Eq("flag", true)), | |
| 23 | + ) | |
| 24 | + | |
| 25 | + assertThat(filters.size).isEqualTo(10) | |
| 26 | + } | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + fun `Filter hierarchy can be exhaustively pattern-matched`() { | |
| 30 | + // The point of sealing Filter is that this `when` is exhaustive without | |
| 31 | + // an `else` branch. If a new variant is added without updating this | |
| 32 | + // test, the compiler fails — which is exactly the safety net we want. | |
| 33 | + fun describe(f: Filter): String = when (f) { | |
| 34 | + is Filter.Eq -> "eq" | |
| 35 | + is Filter.Ne -> "ne" | |
| 36 | + is Filter.Lt -> "lt" | |
| 37 | + is Filter.Gt -> "gt" | |
| 38 | + is Filter.In -> "in" | |
| 39 | + is Filter.Like -> "like" | |
| 40 | + is Filter.IsNull -> "isnull" | |
| 41 | + is Filter.And -> "and" | |
| 42 | + is Filter.Or -> "or" | |
| 43 | + is Filter.Not -> "not" | |
| 44 | + } | |
| 45 | + | |
| 46 | + assertThat(describe(Filter.Eq("a", 1))).isEqualTo("eq") | |
| 47 | + assertThat(describe(Filter.IsNull("x"))).isEqualTo("isnull") | |
| 48 | + assertThat(describe(Filter.Not(Filter.Eq("a", 1)))).isEqualTo("not") | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test | |
| 52 | + fun `Query default values are usable as-is`() { | |
| 53 | + val q = Query() | |
| 54 | + assertThat(q.filters).isEqualTo(emptyList<Filter>()) | |
| 55 | + assertThat(q.sort).isEqualTo(emptyList<SortClause>()) | |
| 56 | + assertThat(q.pageRequest).isEqualTo(PageRequest.FIRST) | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + fun `SortClause carries field and direction`() { | |
| 61 | + val s = SortClause("created_at", SortDirection.DESC) | |
| 62 | + assertThat(s.field).isEqualTo("created_at") | |
| 63 | + assertThat(s.direction).isEqualTo(SortDirection.DESC) | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test | |
| 67 | + fun `nested boolean filters compose`() { | |
| 68 | + val nested: Filter = Filter.And( | |
| 69 | + listOf( | |
| 70 | + Filter.Or(listOf(Filter.Eq("status", "OPEN"), Filter.Eq("status", "PENDING"))), | |
| 71 | + Filter.Not(Filter.IsNull("assignee")), | |
| 72 | + ) | |
| 73 | + ) | |
| 74 | + assertThat(nested).isInstanceOf(Filter.And::class) | |
| 75 | + } | |
| 76 | +} | ... | ... |