• Adds S3FileStorage alongside the existing LocalDiskFileStorage,
    selected at boot by vibeerp.files.backend (local or s3). The
    local backend is the default (matchIfMissing=true) so existing
    deployments are unaffected. Setting backend=s3 activates the S3
    backend with its own config block.
    
    Works with AWS S3, MinIO, DigitalOcean Spaces, or any
    S3-compatible object store via the endpoint-url override. The
    S3 client is lazy-initialized on first use so the bean loads
    even when S3 is unreachable at boot time (useful for tests and
    for the local-disk default path where the S3 bean is never
    instantiated).
    
    Configuration (vibeerp.files.s3.*):
      - bucket (required when backend=s3)
      - region (default: us-east-1)
      - endpoint-url (optional; for MinIO and non-AWS services)
      - access-key + secret-key (optional; falls back to AWS
        DefaultCredentialsProvider chain)
      - key-prefix (optional; namespaces objects so multiple
        instances can share one bucket)
    
    Implementation notes:
      - put() reads the stream into a byte array for S3 (S3
        requires Content-Length up front; chunked upload is a
        future optimization for large files)
      - get() returns the S3 response InputStream directly;
        caller must close it (same contract as local backend)
      - list() paginates via ContinuationToken for buckets with
        >1000 objects per prefix
      - Content-type is stored as native S3 object metadata
        (no sidecar .meta file unlike local backend)
    
    Dependency: software.amazon.awssdk:s3:2.28.6 (AWS SDK v2)
    added to libs.versions.toml and platform-files build.gradle.kts.
    
    LocalDiskFileStorage gained @ConditionalOnProperty(havingValue
    = "local", matchIfMissing = true) so it's the default but
    doesn't conflict when backend=s3.
    
    application.yaml updated with commented-out S3 config block
    documenting all available properties.
    zichun authored
     
    Browse Code »
  • Closes the R2 gap: an admin can now manage users and roles
    entirely from the SPA without touching curl or Swagger UI.
    
    Backend (pbc-identity):
      - New RoleService with createRole, assignRole, revokeRole,
        findUserRoleCodes, listRoles. Each method validates
        existence + idempotency (duplicate assignment rejected,
        missing role rejected).
      - New RoleController at /api/v1/identity/roles (CRUD) +
        /api/v1/identity/users/{userId}/roles/{roleCode}
        (POST assign, DELETE revoke). All permission-gated:
        identity.role.read, identity.role.create,
        identity.role.assign.
      - identity.yml updated: added identity.role.create permission.
    
    SPA (web/):
      - UsersPage — list with username link to detail, "+ New User"
      - CreateUserPage — username, display name, email form
      - UserDetailPage — shows user info + role toggle list. Each
        role has an Assign/Revoke button that takes effect on the
        user's next login (JWT carries roles from login time).
      - RolesPage — list with inline create form (code + name)
      - Sidebar gains "System" section with Users + Roles links
      - API client + types: identity.listUsers, getUser, createUser,
        listRoles, createRole, getUserRoles, assignRole, revokeRole
    
    Infrastructure:
      - SpaController: added /users/** and /roles/** forwarding
      - SecurityConfiguration: added /users/** and /roles/** to the
        SPA permitAll block
    zichun authored
     
    Browse Code »

  • First runnable end-to-end demo: open the browser, log in, click
    through every PBC, and walk a sales order DRAFT → CONFIRMED →
    SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the
    ledger, and the AR journal entry settles — all visible in the SPA
    without touching curl.
    
    Bumps version to 0.29.0-SNAPSHOT.
    
    What landed
    -----------
    
    * New `:web` Gradle subproject — Vite + React 18 + TypeScript +
      Tailwind 3.4. The Gradle wrapper is two `Exec` tasks
      (`npmInstall`, `npmBuild`) with proper inputs/outputs declared
      for incremental builds. Deliberately no node-gradle plugin —
      one less moving piece.
    
    * SPA architecture: hand-written typed REST client over `fetch`
      (auth header injection + 401 handler), AuthContext that decodes
      the JWT for display, ProtectedRoute, AppLayout with sidebar
      grouped by PBC, 16 page components covering the full v1 surface
      (Items, UoMs, Partners, Locations, Stock Balances + Movements,
      Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders +
      detail w/ confirm/receive/cancel, Work Orders + detail w/
      start/complete, Shop-Floor dashboard with 5s polling, Journal
      Entries). 211 KB JS / 21 KB CSS gzipped.
    
    * Sales-order detail page: confirm/ship/cancel verbs each refresh
      the order, the (SO-filtered) movements list, and the
      (SO-filtered) journal entries — so an operator watches the
      ledger row appear and the AR row settle in real time after a
      single click. Same pattern on the purchase-order detail page
      for the AP/RECEIPT side.
    
    * Shop-floor dashboard polls /api/v1/production/work-orders/shop-
      floor every 5s and renders one card per IN_PROGRESS WO with
      current operation, planned vs actual minutes (progress bar),
      and operations-completed.
    
    * `:distribution` consumes the SPA dist via a normal Gradle
      outgoing/incoming configuration: `:web` exposes
      `webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync
      task copies it into `${buildDir}/web-static/static/`, and
      that parent directory is added to the main resources source
      set so Spring Boot serves the SPA from `classpath:/static/`
      out of the same fat-jar. Single artifact, no nginx, no CORS.
    
    * New `SpaController` in platform-bootstrap forwards every
      known SPA route prefix to `/index.html` so React Router's
      HTML5 history mode works on hard refresh / deep-link entry.
      Explicit list (12 prefixes) rather than catch-all so typoed
      API URLs still get an honest 404 instead of the SPA shell.
    
    * SecurityConfiguration restructured: keeps the public allowlist
      for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui,
      then `/api/**` is `.authenticated()`, then SPA static assets
      + every SPA route prefix are `.permitAll()`. The order is
      load-bearing — putting `.authenticated()` for /api/** BEFORE
      the SPA permitAll preserves the framework's "API is always
      authenticated" invariant even with the SPA bundled in the
      same fat-jar. The SPA bundle itself is just HTML+CSS+JS so
      permitting it is correct; secrets are gated by /api/**.
    
    * New `DemoSeedRunner` in `:distribution` (gated behind
      `vibeerp.demo.seed=true`, set in application-dev.yaml only).
      Idempotent — the runner short-circuits if its sentinel item
      (DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses,
      4 partners, opening stock for every item, one open
      DEMO-SO-0001 (50× business cards + 20× brochures, $720), one
      open DEMO-PO-0001 (10000× paper, $400). Every row carries
      the DEMO- prefix so it's trivially distinguishable from
      hand-created data; a future "delete demo data" command has
      an obvious filter. Production deploys never set the property,
      so the @ConditionalOnProperty bean stays absent from the
      context.
    
    How to run
    ----------
    
      docker compose up -d db
      ./gradlew :distribution:bootRun
      open http://localhost:8080
    
      # Read the bootstrap admin password from the boot log,
      # log in as admin, and walk DEMO-SO-0001 through the
      # confirm + ship flow to see the buy-sell loop in the UI.
    
    What was caught by the smoke test
    ---------------------------------
    
    * TypeScript strict mode + `error: unknown` in React state →
      `{error && <X/>}` evaluates to `unknown` and JSX rejects it.
      Fixed by typing the state as `Error | null` and converting
      in catches with `e instanceof Error ? e : new Error(String(e))`.
      Affected 16 page files; the conversion is now uniform.
    
    * DataTable's `T extends Record<string, unknown>` constraint was
      too restrictive for typed row interfaces; relaxed to
      unconstrained `T` with `(row as unknown as Record<…>)[key]`
      for the unkeyed cell read fallback.
    
    * `vite.config.ts` needs `@types/node` for `node:path` +
      `__dirname`; added to devDependencies and tsconfig.node.json
      declares `"types": ["node"]`.
    
    * KDoc nested-comment trap (4th time): SpaController's KDoc
      had `/api/v1/...` in backticks; the `/*` inside backticks
      starts a nested block comment and breaks Kotlin compilation
      with "Unclosed comment". Rephrased to "the api-v1 prefix".
    
    * SecurityConfiguration order: a draft version that put the
      SPA permit-all rules BEFORE `/api/**` authenticated() let
      unauthenticated requests reach API endpoints. Caught by an
      explicit smoke test (curl /api/v1/some-bogus-endpoint should
      return 401, not 404 from a missing static file). Reordered
      so /api/** authentication runs first.
    
    End-to-end smoke (real Postgres, fresh DB)
    ------------------------------------------
    
      - bootRun starts in 7.6s
      - DemoSeedRunner reports "populating starter dataset… done"
      - GET / returns the SPA HTML
      - GET /sales-orders, /sales-orders/<uuid>, /journal-entries
        all return 200 (SPA shell — React Router takes over)
      - GET /assets/index-*.js / /assets/index-*.css both 200
      - GET /api/v1/some-bogus-endpoint → 401 (Spring Security
        rejects before any controller mapping)
      - admin login via /api/v1/auth/login → 200 + JWT
      - GET /catalog/items → 5 DEMO-* rows
      - GET /partners/partners → 4 DEMO-* rows
      - GET /inventory/locations → 2 DEMO-* warehouses
      - GET /inventory/balances → 5 starting balances
      - POST /orders/sales-orders/<id>/confirm → CONFIRMED;
        GET /finance/journal-entries shows AR POSTED 720 USD
      - POST /orders/sales-orders/<id>/ship {"shippingLocationCode":
        "DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80;
        journal entry flips to SETTLED
      - POST /orders/purchase-orders/<id>/confirm → CONFIRMED;
        AP POSTED 400 USD appears
      - POST /orders/purchase-orders/<id>/receive → RECEIVED;
        PAPER balance grows from 5000 to 15000; AP row SETTLED
      - 8 stock_movement rows in the ledger total
    zichun authored
     
    Browse Code »
  • Closes the deferred TODO from the OpenAPI commit (11bef932): every
    endpoint a plug-in registers via `PluginContext.endpoints.register`
    now shows up in the OpenAPI spec alongside the host's
    @RestController operations. Downstream OpenAPI clients (R1 web
    SPA codegen, A1 MCP server tool catalog, operator-side Swagger UI
    browsing) can finally see the customer-specific HTTP surface.
    
    **Problem.** springdoc's default scan walks `@RestController`
    beans on the host classpath. Plug-in endpoints are NOT registered
    that way — they live as lambdas on a single
    `PluginEndpointDispatcher` catch-all controller, so the default
    scan saw ONE dispatcher path and zero per-plug-in detail. The
    printing-shop plug-in's 8 endpoints were entirely invisible to
    the spec.
    
    **Solution: an OpenApiCustomizer bean that queries the registry
    at spec-build time.**
    
    1. `PluginEndpointRegistry.snapshot()` — new public read-only
       view. Returns a list of `(pluginId, method, path)` tuples
       without exposing the handler lambdas. Taken under the
       registry's intrinsic lock and copied out so callers can
       iterate without racing plug-in (un)registration. Ordered by
       registration order for determinism.
    
    2. `PluginEndpointSummary` — new public data class in
       platform-plugins. `pluginId` + `method` + `path` plus a
       `fullPath()` helper that prepends `/api/v1/plugins/<pluginId>`.
    
    3. `PluginEndpointsOpenApiCustomizer @Component` — new class in
       `platform-plugins/openapi/`. Implements
       `org.springdoc.core.customizers.OpenApiCustomizer`. On every
       `/v3/api-docs` request, iterates `registry.snapshot()`,
       groups by full path, and attaches a `PathItem` with one
       `Operation` per registered HTTP verb. Each operation gets:
         - A tag `"Plug-in: <pluginId>"` so Swagger UI groups every
           plug-in's surface under a header
         - A `summary` + `description` naming the plug-in
         - Path parameters auto-extracted from `{name}` segments
         - A generic JSON request body for POST/PUT/PATCH
         - A generic 200 response + 401/403/404 error responses
         - The global bearerAuth security scheme (inherited from
           OpenApiConfiguration, no per-op annotation)
    
    4. `compileOnly(libs.springdoc.openapi.starter.webmvc.ui)` in
       platform-plugins so `OpenApiCustomizer` is visible at compile
       time without dragging the full webmvc-ui bundle into
       platform-plugins' runtime classpath (distribution already
       pulls it in via platform-bootstrap's `implementation`).
    
    5. `implementation(project(":platform:platform-plugins"))` added
       to platform-bootstrap so `OpenApiConfiguration` can inject
       the customizer by type and explicitly wire it to the
       `pluginEndpointsGroup()` `GroupedOpenApi` builder via
       `.addOpenApiCustomizer(...)`. **This is load-bearing** —
       springdoc's grouped specs run their own customizer pipeline
       and do NOT inherit top-level @Component OpenApiCustomizer
       beans. Caught at smoke-test time: initially the customizer
       populated the default /v3/api-docs but the /v3/api-docs/plugins
       group still showed only the dispatcher. Fix was making the
       customizer a constructor-injected dep of OpenApiConfiguration
       and calling `addOpenApiCustomizer` on the group builder.
    
    **What a future chunk might add** (not in this one):
      - Richer per-endpoint JSON Schema — v1 ships unconstrained
        `ObjectSchema` request/response bodies because the framework
        has no per-endpoint shape info at the registrar layer. A
        future `PluginEndpointRegistrar` overload accepting an
        explicit schema would let plug-ins document their payloads.
      - Per-endpoint `@RequirePermission` surface — the dispatcher
        enforces permissions at runtime but doesn't record them on
        the registration, so the OpenAPI spec doesn't list them.
    
    **KDoc `/**` trap caught.** A literal plug-in URL pattern in
    the customizer's KDoc (`/api/v1/plugins/{pluginId}/**`) tripped
    the Kotlin nested-comment parser again. Rephrased as "under the
    `/api/v1/plugins/{pluginId}` prefix" to sidestep. Third time
    this trap has bitten me — the workaround is in feedback memory.
    
    **HttpMethod enum caught.** Initial `when` branch on the customizer
    covered HEAD/OPTIONS which don't exist in the api.v1 `HttpMethod`
    enum (only GET/POST/PUT/PATCH/DELETE). Dropped those branches.
    
    **Smoke-tested end-to-end against real Postgres:**
      - GET /v3/api-docs/plugins returns 7 paths:
          - /api/v1/plugins/printing-shop/echo/{name}       GET
          - /api/v1/plugins/printing-shop/inks              GET, POST
          - /api/v1/plugins/printing-shop/ping              GET
          - /api/v1/plugins/printing-shop/plates            GET, POST
          - /api/v1/plugins/printing-shop/plates/{id}       GET
          - /api/v1/plugins/printing-shop/plates/{id}/generate-quote-pdf  POST
          - /api/v1/plugins/{pluginId}/**                   (dispatcher fallback)
        Before this chunk: only the dispatcher fallback (1 path).
      - Top-level /v3/api-docs now also includes the 6 printing-shop
        paths it previously didn't.
      - All 7 printing-shop endpoints remain functional at the real
        dispatcher (no behavior change — this is a documentation-only
        enhancement).
    
    24 modules, 355 unit tests, all green.
    zichun authored
     
    Browse Code »
  • Adds 15 GroupedOpenApi beans that split the single giant OpenAPI
    spec into per-PBC + per-platform-module focused specs selectable
    from Swagger UI's top-right "Select a definition" dropdown. No
    @RestController changes — all groups are defined by URL prefix
    in platform-bootstrap, so adding a new PBC means touching exactly
    this file (plus the controller itself). Each group stays
    additive alongside the default /v3/api-docs.
    
    **Groups shipped:**
      Platform
        platform-core     — /api/v1/auth/**, /api/v1/_meta/**
        platform-workflow — /api/v1/workflow/**
        platform-jobs     — /api/v1/jobs/**
        platform-files    — /api/v1/files/**
        platform-reports  — /api/v1/reports/**
      Core PBCs
        pbc-identity     — /api/v1/identity/**
        pbc-catalog      — /api/v1/catalog/**
        pbc-partners     — /api/v1/partners/**
        pbc-inventory    — /api/v1/inventory/**
        pbc-warehousing  — /api/v1/warehousing/**
        pbc-orders       — /api/v1/orders/** (sales + purchase together)
        pbc-production   — /api/v1/production/**
        pbc-quality      — /api/v1/quality/**
        pbc-finance      — /api/v1/finance/**
      Plug-in dispatcher
        plugins          — /api/v1/plugins/**
    
    **Why path-prefix grouping, not package-scan grouping.**
    Package-scan grouping would force OpenApiConfiguration to know
    every PBC's Kotlin package name and drift every time a PBC ships
    or a controller moves. Path-prefix grouping only shifts when
    `@RequestMapping` changes — which is already a breaking API
    change that would need review anyway. This keeps the control
    plane for grouping in one file while the routing stays in each
    controller.
    
    **Why pbc-orders is one group, not split sales/purchase.**
    Both controllers share the `/api/v1/orders/` prefix, and sales /
    purchase are the same shape in practice — splitting them into
    two groups would just duplicate the dropdown entries. A future
    chunk can split if a real consumer asks for it.
    
    **Primary group unchanged.** The default /v3/api-docs continues
    to return the full merged spec (every operation in one
    document). The grouped specs are additive at
    /v3/api-docs/<group-name> and clients can pick whichever they
    need. Swagger UI defaults to showing the first group in the
    dropdown.
    
    **Smoke-tested end-to-end against real Postgres:**
      - GET /v3/api-docs/swagger-config returns 15 groups with
        human-readable display names
      - Per-group path counts (confirming each group is focused):
        pbc-production: 10 paths
        pbc-catalog:     6 paths
        pbc-orders:     12 paths
        platform-core:   9 paths
        platform-files:  3 paths
        plugins:         1 path (dispatcher)
      - Default /v3/api-docs continues to return the full spec.
    
    24 modules, 355 unit tests, all green.
    zichun authored
     
    Browse Code »
  • Closes a 15-commit-old TODO on MetaController and unifies the
    version story across /api/v1/_meta/info, /v3/api-docs, and the
    api-v1.jar manifest.
    
    **Build metadata wiring.** `distribution/build.gradle.kts` now
    calls `buildInfo()` inside the `springBoot { }` block. This makes
    Spring Boot's Gradle plug-in write `META-INF/build-info.properties`
    into the bootJar at build time with group / artifact / version /
    build time pulled from `project.version` + timestamps. Spring
    Boot's `BuildInfoAutoConfiguration` then exposes a `BuildProperties`
    bean that injection points can consume.
    
    **MetaController enriched.** Now injects:
      - `ObjectProvider<BuildProperties>` — returns the real version
        (`0.28.0-SNAPSHOT`) and the build timestamp when packaged
        through the distribution bootJar; falls back to `0.0.0-test`
        inside a bare platform-bootstrap unit test classloader with
        no build-info file on the classpath.
      - `Environment` — returns `spring.profiles.active` so a
        dashboard can distinguish "dev" from "staging" from a prod
        container that activates no profile.
    
    The GET /api/v1/_meta/info response now carries:
      - `name`, `apiVersion` — unchanged
      - `implementationVersion` — from BuildProperties (was stuck at
        "0.1.0-SNAPSHOT" via an unreachable `javaClass.package` lookup)
      - `buildTime` — ISO-8601 string from BuildProperties, null if
        the classpath has no build-info file
      - `activeProfiles` — list of effective spring profiles
    
    **OpenApiConfiguration now reads version from BuildProperties too.**
    Previously OPENAPI_INFO_VERSION was a hardcoded "v0.28.0"
    constant. Now it's injected via ObjectProvider<BuildProperties>
    with the same fallback pattern as MetaController. A single
    version bump in gradle.properties now flows to:
      gradle.properties
        → Spring Boot's buildInfo()
        → build-info.properties (on the classpath)
        → BuildProperties bean
        → MetaController (/_meta/info)
        → OpenApiConfiguration (/v3/api-docs + Swagger UI)
        → api-v1.jar manifest (already wired)
    
    No more hand-maintained version strings in code. Bump
    `vibeerp.version` in gradle.properties and every display follows.
    
    **Version bump.** `gradle.properties` `vibeerp.version`:
    `0.1.0-SNAPSHOT` → `0.28.0-SNAPSHOT`. This matches the numeric
    label used on PROGRESS.md's "Latest version" row and carries a
    documentation comment explaining the propagation chain so the
    next person bumping it knows what to update alongside (just the
    one line + PROGRESS.md).
    
    **KDoc trap caught.** A literal `/api/v1/_meta/**` path pattern
    in MetaController's KDoc tripped the Kotlin nested-comment
    parser (`/**` starts a KDoc). Rephrased as "the whole `/api/v1/_meta`
    prefix" to sidestep the trap — same workaround I saved in
    feedback memory after the first time it bit me.
    
    **Smoke-tested end-to-end against real Postgres:**
      - GET /api/v1/_meta/info returns
        `{"implementationVersion": "0.28.0-SNAPSHOT",
          "buildTime": "2026-04-09T09:48:25.646Z",
          "activeProfiles": ["dev"]}`
      - GET /v3/api-docs `info.version` = "0.28.0-SNAPSHOT" (was
        the hardcoded "v0.28.0" constant before this chunk)
      - Single edit to gradle.properties propagates cleanly.
    
    24 modules, 355 unit tests, all green.
    zichun authored
     
    Browse Code »
  • Adds self-introspection of the framework's REST surface via
    springdoc-openapi. Every @RestController method in the host
    application is now documented in a machine-readable OpenAPI 3
    spec at /v3/api-docs and rendered for humans at
    /swagger-ui/index.html. This is the first step toward:
    
      - R1 (web SPA): OpenAPI codegen feeds a typed TypeScript client
      - A1 (MCP server): discoverable tool catalog
      - Operator debugging: browsable "what can this instance do" page
    
    **Dependency.** New `springdoc-openapi-starter-webmvc-ui` 2.6.0
    added to platform-bootstrap (not distribution) because it ships
    @Configuration classes that need to run inside a full Spring Boot
    application context AND brings a Swagger UI WebJar. platform-bootstrap
    is the only module with a @SpringBootApplication anyway; pbc
    modules never depend on it, so plug-in classloaders stay clean
    and the OpenAPI scanner only sees host controllers.
    
    **Configuration.** New `OpenApiConfiguration` @Configuration in
    platform-bootstrap provides a single @Bean OpenAPI:
      - Title "vibe_erp", version v0.28.0 (hardcoded; moves to a
        build property when a real version header ships)
      - Description with a framework-level intro explaining the
        bearer-JWT auth model, the permission whitelist, and the
        fact that plug-in endpoints under /api/v1/plugins/{id}/** are
        NOT scanned (they are dynamically registered via
        PluginContext.endpoints on a single dispatcher controller;
        a future chunk may extend the spec at runtime).
      - One relative server entry ("/") so the spec works behind a
        reverse proxy without baking localhost into it.
      - bearerAuth security scheme (HTTP/bearer/JWT) applied globally
        via addSecurityItem, so every operation in the rendered UI
        shows a lock icon and the "Authorize" button accepts a raw
        JWT (Swagger adds the "Bearer " prefix itself).
    
    **Security whitelist.** SecurityConfiguration now permits three
    additional path patterns without authentication:
      - /v3/api-docs/** — the generated JSON spec
      - /swagger-ui/** — the Swagger UI static assets + index
      - /swagger-ui.html — the legacy path (redirects to the above)
    The data still requires a valid JWT: an unauthenticated "Try it
    out" call from the Swagger UI against a pbc endpoint returns 401
    exactly like a curl would.
    
    **Why not wire this into every PBC controller with @Operation /
    @Parameter annotations in this chunk:** springdoc already
    auto-generates the full path + request body + response schema
    from reflection. Adding hand-written annotations is scope creep —
    a future chunk can tag per-operation @Operation(security = ...)
    to surface the @RequirePermission keys once a consumer actually
    needs them.
    
    **Smoke-tested end-to-end against real Postgres:**
      - GET /v3/api-docs returns 200 with 64680 bytes of OpenAPI JSON
      - 76 total paths listed across every PBC controller
      - All v3 production paths present: /work-orders/shop-floor,
        /work-orders/{id}/operations/{operationId}/start + /complete,
        /work-orders/{id}/{start,complete,cancel,scrap}
      - components.securitySchemes includes bearerAuth (type=http,
        format=JWT)
      - GET /swagger-ui/index.html returns 200 with the Swagger HTML
        bundle (5 swagger markers found in the HTML)
      - GET /swagger-ui.html (legacy path) returns 200 after redirect
    
    25 modules (unchanged count — new config lives inside
    platform-bootstrap), 355 unit tests, all green.
    zichun authored
     
    Browse Code »
  • Closes two open wiring gaps left by the P1.9 and P1.8 chunks —
    `PluginContext.files` and `PluginContext.reports` both previously
    threw `UnsupportedOperationException` because the host's
    `DefaultPluginContext` never received the concrete beans. This
    commit plumbs both through and exercises them end-to-end via a
    new printing-shop plug-in endpoint that generates a quote PDF,
    stores it in the file store, and returns the file handle.
    
    With this chunk the reference printing-shop plug-in demonstrates
    **every extension seam the framework provides**: HTTP endpoints,
    JDBC, metadata YAML, i18n, BPMN + TaskHandlers, JobHandlers,
    custom fields on core entities, event publishing via EventBus,
    ReportRenderer, and FileStorage. There is no major public plug-in
    surface left unexercised.
    
    ## Wiring: DefaultPluginContext + VibeErpPluginManager
    
    - `DefaultPluginContext` gains two new constructor parameters
      (`sharedFileStorage: FileStorage`, `sharedReportRenderer: ReportRenderer`)
      and two new overrides. Each is wired via Spring — they live in
      platform-files and platform-reports respectively, but
      platform-plugins only depends on api.v1 (the interfaces) and
      NOT on those modules directly. The concrete beans are injected
      by Spring at distribution boot time when every `@Component` is
      on the classpath.
    - `VibeErpPluginManager` adds `private val fileStorage: FileStorage`
      and `private val reportRenderer: ReportRenderer` constructor
      params and passes them through to every `DefaultPluginContext`
      it builds per plug-in.
    
    The `files` and `reports` getters in api.v1 `PluginContext` still
    have their default-throw backward-compat shim — a plug-in built
    against v0.8 of api.v1 loading on a v0.7 host would still fail
    loudly at first call with a clear "upgrade to v0.8" message. The
    override here makes the v0.8+ host honour the interface.
    
    ## Printing-shop reference — quote PDF endpoint
    
    - New `resources/reports/quote-template.jrxml` inside the plug-in
      JAR. Parameters: plateCode, plateName, widthMm, heightMm,
      status, customerName. Produces a single-page A4 PDF with a
      header, a table of plate attributes, and a footer.
    
    - New endpoint `POST /api/v1/plugins/printing-shop/plates/{id}/generate-quote-pdf`.
      Request body `{"customerName": "..."}`, response:
        `{"plateId", "plateCode", "customerName",
          "fileKey", "fileSize", "fileContentType", "downloadUrl"}`
    
      The handler does ALL of:
        1. Reads the plate row via `context.jdbc.queryForObject(...)`
        2. Loads the JRXML from the PLUG-IN's own classloader (not
           the host classpath — `this::class.java.classLoader
           .getResourceAsStream("reports/quote-template.jrxml")` —
           so the host's built-in `vibeerp-ping-report.jrxml` and the
           plug-in's template live in isolated namespaces)
        3. Renders via `context.reports.renderPdf(template, data)`
           — uses the host JasperReportRenderer under the hood
        4. Persists via `context.files.put(key, contentType, content)`
           under a plug-in-scoped key `plugin-printing-shop/quotes/quote-<code>.pdf`
        5. Returns the file handle plus a `downloadUrl` pointing at
           the framework's `/api/v1/files/download` endpoint the
           caller can immediately hit
    
    ## Smoke test (fresh DB + staged plug-in)
    
    ```
    # create a plate
    POST /api/v1/plugins/printing-shop/plates
         {code: PLATE-200, name: "Premium cover", widthMm: 420, heightMm: 594}
      → 201 {id, code: PLATE-200, status: DRAFT, ...}
    
    # generate + store the quote PDF
    POST /api/v1/plugins/printing-shop/plates/<id>/generate-quote-pdf
         {customerName: "Acme Inc"}
      → 201 {
          plateId, plateCode: "PLATE-200", customerName: "Acme Inc",
          fileKey: "plugin-printing-shop/quotes/quote-PLATE-200.pdf",
          fileSize: 1488,
          fileContentType: "application/pdf",
          downloadUrl: "/api/v1/files/download?key=plugin-printing-shop/quotes/quote-PLATE-200.pdf"
        }
    
    # download via the framework's file endpoint
    GET /api/v1/files/download?key=plugin-printing-shop/quotes/quote-PLATE-200.pdf
      → 200
        Content-Type: application/pdf
        Content-Length: 1488
        body: valid PDF 1.5, 1 page
    
    $ file /tmp/plate-quote.pdf
      /tmp/plate-quote.pdf: PDF document, version 1.5, 1 pages (zip deflate encoded)
    
    # list by prefix
    GET /api/v1/files?prefix=plugin-printing-shop/
      → [{"key":"plugin-printing-shop/quotes/quote-PLATE-200.pdf",
          "size":1488, "contentType":"application/pdf", ...}]
    
    # plug-in log
    [plugin:printing-shop] registered 8 endpoints under /api/v1/plugins/printing-shop/
    [plugin:printing-shop] generated quote PDF for plate PLATE-200 (1488 bytes)
                           → plugin-printing-shop/quotes/quote-PLATE-200.pdf
    ```
    
    Four public surfaces composed in one flow: plug-in JDBC read →
    plug-in classloader resource load → host ReportRenderer compile/
    fill/export → host FileStorage put → host file controller
    download. Every step stays on api.v1; zero plug-in code reaches
    into a concrete platform class.
    
    ## Printing-shop plug-in — full extension surface exercised
    
    After this commit the reference printing-shop plug-in contributes
    via every public seam the framework offers:
    
    | Seam                          | How the plug-in uses it                                |
    |-------------------------------|--------------------------------------------------------|
    | HTTP endpoints (P1.3)         | 8 endpoints under /api/v1/plugins/printing-shop/       |
    | JDBC (P1.4)                   | Reads/writes its own plugin_printingshop__* tables    |
    | Liquibase                     | Own changelog.xml, 2 tables created at plug-in start  |
    | Metadata YAML (P1.5)          | 2 entities, 5 permissions, 2 menus                    |
    | Custom fields on CORE (P3.4)  | 5 plug-in fields on Partner/Item/SalesOrder/WorkOrder |
    | i18n (P1.6)                   | Own messages_<locale>.properties, quote number msgs   |
    | EventBus (P1.7)               | Publishes WorkOrderRequestedEvent from a TaskHandler  |
    | TaskHandlers (P2.1)           | 2 handlers (plate-approval, quote-to-work-order)      |
    | Plug-in BPMN (P2.1 followup)  | 2 BPMNs in processes/ auto-deployed at start          |
    | JobHandlers (P1.10 followup)  | PlateCleanupJobHandler using context.jdbc + logger    |
    | ReportRenderer (P1.8)         | Quote PDF from JRXML via context.reports              |
    | FileStorage (P1.9)            | Persists quote PDF via context.files                  |
    
    Everything listed in this table is exercised end-to-end by the
    current smoke test. The plug-in is the framework's executable
    acceptance test for the entire public extension surface.
    
    ## Tests
    
    No new unit tests — the wiring change is a plain constructor
    addition, the existing `DefaultPluginContext` has no dedicated
    test class (it's a thin dataclass-shaped bean), and
    `JasperReportRenderer` + `LocalDiskFileStorage` each have their
    own unit tests from the respective parent chunks. The change is
    validated end-to-end by the above smoke test; formalizing that
    into an integration test would need Testcontainers + a real
    plug-in JAR and belongs to a different (test-infra) chunk.
    
    - Total framework unit tests: 337 (unchanged), all green.
    
    ## Non-goals (parking lot)
    
    - Pre-compiled `.jasper` caching keyed by template hash. A
      hot-path benchmark would tell us whether the cache is worth
      shipping.
    - Multipart upload of a template into a plug-in's own `files`
      namespace so non-bundled templates can be tried without a
      plug-in rebuild. Nice-to-have for iteration but not on the
      v1.0 critical path.
    - Scoped file-key prefixes per plug-in enforced by the framework
      (today the plug-in picks its own prefix by convention; a
      `plugin.files.keyPrefix` config would let the host enforce
      that every plug-in-contributed file lives under
      `plugin-<id>/`). Future hardening chunk.
    zichun authored
     
    Browse Code »
  • Closes the P1.8 row of the implementation plan — **every Phase 1
    platform unit is now ✅**. New platform-reports subproject wrapping
    JasperReports 6.21.3 with a minimal api.v1 ReportRenderer facade,
    a built-in self-test JRXML template, and a thin HTTP surface.
    
    ## api.v1 additions (package `org.vibeerp.api.v1.reports`)
    
    - `ReportRenderer` — injectable facade with ONE method for v1:
        `renderPdf(template: InputStream, data: Map<String, Any?>): ByteArray`
      Caller loads the JRXML (or pre-compiled .jasper) from wherever
      (plug-in JAR classpath, FileStorage, DB metadata row, HTTP
      upload) and hands an open stream to the renderer. The framework
      reads the bytes, compiles/fills/exports, and returns the PDF.
    - `ReportRenderException` — wraps any engine exception so plug-ins
      don't have to import concrete Jasper exception types.
    - `PluginContext.reports: ReportRenderer` — new optional member
      with the default-throw backward-compat pattern used for every
      other addition. Plug-ins that ship quote PDFs, job cards,
      delivery notes, etc. inject this through the context.
    
    ## platform-reports runtime
    
    - `JasperReportRenderer` @Component — wraps JasperReports' compile
      → fill → export cycle into one method.
        * `JasperCompileManager.compileReport(template)` turns the
          JRXML stream into an in-memory `JasperReport`.
        * `JasperFillManager.fillReport(compiled, params, JREmptyDataSource(1))`
          evaluates expressions against the parameter map. The empty
          data source satisfies Jasper's requirement for a non-null
          data source when the template has no `<field>` definitions.
        * `JasperExportManager.exportReportToPdfStream(jasperPrint, buffer)`
          produces the PDF bytes. The `JasperPrint` type annotation on
          the local is deliberate — Jasper has an ambiguous
          `exportReportToPdfStream(InputStream, OutputStream)` overload
          and Kotlin needs the explicit type to pick the right one.
        * Every stage catches `Throwable` and re-throws as
          `ReportRenderException` with a useful message, keeping the
          api.v1 surface clean of Jasper's exception hierarchy.
    
    - `ReportController` at `/api/v1/reports/**`:
        * `POST /ping`    render the built-in self-test JRXML with
                          the supplied `{name: "..."}` (optional, defaults
                          to "world") and return the PDF bytes with
                          `application/pdf` Content-Type
        * `POST /render`  multipart upload a JRXML template + return
                          the PDF. Operator / test use, not the main
                          production path.
      Both endpoints @RequirePermission-gated via `reports.report.render`.
    
    - `reports/vibeerp-ping-report.jrxml` — a single-page JRXML with
      a title, centred "Hello, $P{name}!" text, and a footer. Zero
      fields, one string parameter with a default value. Ships on the
      platform-reports classpath and is loaded by the `/ping` endpoint
      via `ClassPathResource`.
    
    - `META-INF/vibe-erp/metadata/reports.yml` — 1 permission + 1 menu.
    
    ## Design decisions captured in-file
    
    - **No template compilation cache.** Every call compiles the JRXML
      fresh. Fine for infrequent reports (quotes, job cards); a hot
      path that renders thousands of the same report per minute would
      want a `ConcurrentHashMap<String, JasperReport>` keyed by
      template hash. Deliberately NOT shipped until a benchmark shows
      it's needed — the cache key semantics need a real consumer.
    - **No multiple output formats.** v1 is PDF-only. Additive
      overloads for HTML/XLSX land when a real consumer needs them.
    - **No data-source argument.** v1 is parameter-driven, not
      query-driven. A future `renderPdf(template, data, rows)`
      overload will take tabular data for `<field>`-based templates.
    - **No Groovy / Janino / ECJ.** The default `JRJavacCompiler` uses
      `javax.tools.ToolProvider.getSystemJavaCompiler()` which is
      available on any JDK runtime. vibe_erp already requires a JDK
      (not JRE) for Liquibase + Flowable + Quartz, so we inherit this
      for free. Zero extra compiler dependencies.
    
    ## Config trap caught during first build (documented in build.gradle.kts)
    
    My first attempt added aggressive JasperReports exclusions to
    shrink the transitive dep tree (POI, Batik, Velocity, Castor,
    Groovy, commons-digester, ...). The build compiled fine but
    `JasperCompileManager.compileReport(...)` threw
    `ClassNotFoundException: org.apache.commons.digester.Digester`
    at runtime — Jasper uses Digester internally to parse the JRXML
    structure, and excluding the transitive dep silently breaks
    template loading.
    
    Fix: remove ALL exclusions. JasperReports' dep tree IS heavy,
    but each transitive is load-bearing for a use case that's only
    obvious once you exercise the engine end-to-end. A benchmark-
    driven optimization chunk can revisit this later if the JAR size
    becomes a concern; for v1.0 the "just pull it all in" approach is
    correct. Documented in the build.gradle.kts so the next person
    who thinks about trimming the dep tree reads the warning first.
    
    ## Smoke test (fresh DB, as admin)
    
    ```
    POST /api/v1/reports/ping {"name": "Alice"}
      → 200
        Content-Type: application/pdf
        Content-Length: 1436
        body: %PDF-1.5 ... (valid 1-page PDF)
    
    $ file /tmp/ping-report.pdf
      /tmp/ping-report.pdf: PDF document, version 1.5, 1 pages (zip deflate encoded)
    
    POST /api/v1/reports/ping   (no body)
      → 200, 1435 bytes, renders with default name="world" from JRXML
        defaultValueExpression
    
    # negative
    POST /api/v1/reports/render  (multipart with garbage bytes)
      → 400 {"message": "failed to compile JRXML template:
             org.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1;
             Content is not allowed in prolog."}
    
    GET /api/v1/_meta/metadata
      → permissions includes "reports.report.render"
    ```
    
    The `%PDF-` magic header is present and the `file` command on
    macOS identifies the bytes as a valid PDF 1.5 single-page document.
    JasperReports compile + fill + export are all running against
    the live JDK 21 javac inside the Spring Boot app on first boot.
    
    ## Tests
    
    - 3 new unit tests in `JasperReportRendererTest`:
      * `renders the built-in ping template to a valid PDF byte stream`
        — checks for the `%PDF-` magic header and a reasonable size
      * `renders with the default parameter when the data map is empty`
        — proves the JRXML's defaultValueExpression fires
      * `wraps compile failures in ReportRenderException` — feeds
        garbage bytes and asserts the exception type
    - Total framework unit tests: 337 (was 334), all green.
    
    ## What this unblocks
    
    - **Printing-shop quote PDFs.** The reference plug-in can now ship
      a `reports/quote.jrxml` in its JAR, load it in an HTTP handler
      via classloader, render via `context.reports.renderPdf(...)`,
      and either return the PDF bytes directly or persist it via
      `context.files.put("reports/quote-$code.pdf", "application/pdf", ...)`
      for later download. The P1.8 → P1.9 chain is ready.
    - **Job cards, delivery notes, pick lists, QC certificates.**
      Every business document in a printing shop is a report
      template + a data payload. The facade handles them all through
      the same `renderPdf` call.
    - **A future reports PBC.** When a PBC actually needs report
      metadata persisted (template versioning, report scheduling), a
      new pbc-reports can layer on top without changing api.v1 —
      the renderer stays the lowest-level primitive, the PBC becomes
      the management surface.
    
    ## Phase 1 completion
    
    With P1.8 landed:
    
    | Unit | Status |
    |------|--------|
    | P1.2 Plug-in linter        | ✅ |
    | P1.3 Plug-in HTTP + lifecycle | ✅ |
    | P1.4 Plug-in Liquibase + PluginJdbc | ✅ |
    | P1.5 Metadata store + loader | ✅ |
    | P1.6 ICU4J translator | ✅ |
    | P1.7 Event bus + outbox | ✅ |
    | P1.8 JasperReports integration | ✅ |
    | P1.9 File store | ✅ |
    | P1.10 Quartz scheduler | ✅ |
    
    **All nine Phase 1 platform units are now done.** (P1.1 Postgres RLS
    was removed by the early single-tenant refactor, per CLAUDE.md
    guardrail #5.) Remaining v1.0 work is cross-cutting: pbc-finance
    GL growth, the web SPA (R1–R4), OIDC (P4.2), the MCP server (A1),
    and richer per-PBC v2/v3 scopes.
    
    ## Non-goals (parking lot)
    
    - Template caching keyed by hash.
    - HTML/XLSX exporters.
    - Pre-compiled `.jasper` support via a Gradle build task.
    - Sub-reports (master-detail).
    - Dependency-tree optimisation via selective exclusions — needs a
      benchmark-driven chunk to prove each exclusion is safe.
    - Plug-in loader integration for custom font embedding. Jasper's
      default fonts work; custom fonts land when a real customer
      plug-in needs them.
    zichun authored
     
    Browse Code »
  • P1.10 follow-up. Plug-ins can now register background job handlers
    the same way they already register workflow task handlers. The
    reference printing-shop plug-in ships a real PlateCleanupJobHandler
    that reads from its own database via `context.jdbc` as the
    executable acceptance test.
    
    ## Why this wasn't in the P1.10 chunk
    
    P1.10 landed the core scheduler + registry + Quartz bridge + HTTP
    surface, but the plug-in-loader integration was deliberately
    deferred — the JobHandlerRegistry already supported owner-tagged
    `register(handler, ownerId)` and `unregisterAllByOwner(ownerId)`,
    so the seam was defined; it just didn't have a caller from the
    PF4J plug-in side. Without a real plug-in consumer, shipping the
    integration would have been speculative.
    
    This commit closes the gap in exactly the shape the TaskHandler
    side already has: new api.v1 registrar interface, new scoped
    registrar in platform-plugins, one constructor parameter on
    DefaultPluginContext, one new field on VibeErpPluginManager, and
    the teardown paths all fall out automatically because
    JobHandlerRegistry already implements the owner-tagged cleanup.
    
    ## api.v1 additions
    
    - `org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar` — single
      method `register(handler: JobHandler)`. Mirrors
      `PluginTaskHandlerRegistrar` exactly, same ergonomics, same
      duplicate-key-throws discipline.
    - `PluginContext.jobs: PluginJobHandlerRegistrar` — new optional
      member with the default-throw backward-compat pattern used for
      `endpoints`, `jdbc`, `taskHandlers`, and `files`. An older host
      loading a newer plug-in jar fails loudly at first call rather
      than silently dropping scheduled work.
    
    ## platform-plugins wiring
    
    - New dependency on `:platform:platform-jobs`.
    - New internal class
      `org.vibeerp.platform.plugins.jobs.ScopedJobHandlerRegistrar`
      that implements the api.v1 registrar by delegating
      `register(handler)` to `hostRegistry.register(handler, ownerId = pluginId)`.
    - `DefaultPluginContext` gains a `scopedJobHandlers` constructor
      parameter and exposes it as `PluginContext.jobs`.
    - `VibeErpPluginManager`:
      * injects `JobHandlerRegistry`
      * constructs `ScopedJobHandlerRegistrar(registry, pluginId)` per
        plug-in when building `DefaultPluginContext`
      * partial-start failure now also calls
        `jobHandlerRegistry.unregisterAllByOwner(pluginId)`, matching
        the existing endpoint + taskHandler + BPMN-deployment cleanups
      * `destroy()` reverse-iterates `started` and calls the same
        `unregisterAllByOwner` alongside the other four teardown steps
    
    ## Reference plug-in — PlateCleanupJobHandler
    
    New file
    `reference-customer/plugin-printing-shop/.../jobs/PlateCleanupJobHandler.kt`.
    Key `printing_shop.plate.cleanup`. Captures the `PluginContext`
    via constructor — same "handler-side plug-in context access"
    pattern the printing-shop plug-in already uses for its
    TaskHandlers.
    
    The handler is READ-ONLY in its v1 incarnation: it runs a
    GROUP-BY query over `plugin_printingshop__plate` via
    `context.jdbc.query(...)` and logs a per-status summary via
    `context.logger.info(...)`. A real cleanup job would also run an
    `UPDATE`/`DELETE` to prune DRAFT plates older than N days; the
    read-only shape is enough to exercise the seam end-to-end without
    introducing a retention policy the customer hasn't asked for.
    
    `PrintingShopPlugin.start(context)` now registers the handler
    alongside its two TaskHandlers:
    
        context.taskHandlers.register(PlateApprovalTaskHandler(context))
        context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))
        context.jobs.register(PlateCleanupJobHandler(context))
    
    ## Smoke test (fresh DB, plug-in staged)
    
    ```
    # boot
    registered JobHandler 'vibeerp.jobs.ping' owner='core' ...
    JobHandlerRegistry initialised with 1 core JobHandler bean(s): [vibeerp.jobs.ping]
    ...
    registered JobHandler 'printing_shop.plate.cleanup' owner='printing-shop' ...
    [plugin:printing-shop] registered 1 JobHandler: printing_shop.plate.cleanup
    
    # HTTP: list handlers — now shows both
    GET /api/v1/jobs/handlers
      → {"count":2,"keys":["printing_shop.plate.cleanup","vibeerp.jobs.ping"]}
    
    # HTTP: trigger the plug-in handler — proves dispatcher routes to it
    POST /api/v1/jobs/handlers/printing_shop.plate.cleanup/trigger
      → 200 {"handlerKey":"printing_shop.plate.cleanup",
             "correlationId":"95969129-d6bf-4d9a-8359-88310c4f63b9",
             "startedAt":"...","finishedAt":"...","ok":true}
    
    # Handler-side logs prove context.jdbc + context.logger access
    [plugin:printing-shop] PlateCleanupJobHandler firing corr='95969129-...'
    [plugin:printing-shop] PlateCleanupJobHandler summary: total=0 byStatus=[]
    
    # SIGTERM — clean teardown
    [ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 2 handler(s)
    [ionShutdownHook] unregistered JobHandler 'printing_shop.plate.cleanup' (owner stopped)
    [ionShutdownHook] JobHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
    ```
    
    Every expected lifecycle event fires in the right order. Core
    handlers are untouched by plug-in teardown.
    
    ## Tests
    
    No new unit tests in this commit — the test coverage is inherited
    from the previously landed components:
      - `JobHandlerRegistryTest` already covers owner-tagged
        `register` / `unregister` / `unregisterAllByOwner` / duplicate
        key rejection.
      - `ScopedTaskHandlerRegistrar` behavior (which this commit
        mirrors structurally) is exercised end-to-end by the
        printing-shop plug-in boot path.
    - Total framework unit tests: 334 (unchanged from the
      quality→warehousing quarantine chunk), all green.
    
    ## What this unblocks
    
    - **Plug-in-shipped scheduled work.** The printing-shop plug-in
      can now add cron schedules for its cleanup handler via
      `POST /api/v1/jobs/scheduled {scheduleKey, handlerKey,
      cronExpression}` without the operator touching core code.
    - **Plug-in-to-plug-in handler coexistence.** Two plug-ins can
      now ship job handlers with distinct keys and be torn down
      independently on reload — the owner-tagged cleanup strips only
      the stopping plug-in's handlers, leaving other plug-ins' and
      core handlers alone.
    - **The "plug-in contributes everything" story.** The reference
      printing-shop plug-in now contributes via every public seam the
      framework has: HTTP endpoints (7), custom fields on core
      entities (5), BPMNs (2), TaskHandlers (2), and a JobHandler (1)
      — plus its own database schema, its own metadata YAML, its own
      i18n bundles. That's every extension point a real customer
      plug-in would want.
    
    ## Non-goals (parking lot)
    
    - A real retention policy in PlateCleanupJobHandler. The handler
      logs a summary but doesn't mutate state. Customer-specific
      pruning rules belong in a customer-owned plug-in or a metadata-
      driven rule once that seam exists.
    - A built-in cron schedule for the plug-in's handler. The
      plug-in only registers the handler; scheduling is an operator
      decision exposed through the HTTP surface from P1.10.
    zichun authored
     
    Browse Code »
  • Closes the P1.9 row of the implementation plan. New platform-files
    subproject exposing a cross-PBC facade for the framework's binary
    blob store, with a local-disk implementation and a thin HTTP
    surface for multipart upload / download / delete / list.
    
    ## api.v1 additions (package `org.vibeerp.api.v1.files`)
    
    - `FileStorage` — injectable facade with five methods:
        * `put(key, contentType, content: InputStream): FileHandle`
        * `get(key): FileReadResult?`
        * `exists(key): Boolean`
        * `delete(key): Boolean`
        * `list(prefix): List<FileHandle>`
      Stream-first (not byte-array-first) so reports PDFs etc. don't
      have to be materialized in memory. Keys are opaque strings with
      slashes allowed for logical grouping; the local-disk backend
      maps them to subdirectories.
    
    - `FileHandle` — read-only metadata DTO (key, size, contentType,
      createdAt, updatedAt).
    
    - `FileReadResult` — the return type of `get()` bundling a handle
      and an open InputStream. The caller MUST close the stream
      (`result.content.use { ... }` is the idiomatic shape); the
      facade is not responsible for managing the consumer's lifetime.
    
    - `PluginContext.files: FileStorage` — new member on the plug-in
      context interface, default implementation throws
      `UnsupportedOperationException("upgrade vibe_erp to v0.8 or later")`.
      Same backward-compat pattern we used for `endpoints`, `jdbc`,
      `taskHandlers`. Plug-ins that need to persist report PDFs,
      uploaded attachments, or exported archives inject this through
      the context.
    
    ## platform-files runtime
    
    - `LocalDiskFileStorage` @Component reading `vibeerp.files.local-path`
      (default `./files-local`, overridden in dev profile to
      `./files-dev`, overridden in production config to
      `/opt/vibe-erp/files`).
    
      **Layout**: files are stored at `<root>/<key>` with a sidecar
      metadata file at `<root>/<key>.meta` containing a single line
      `content_type=<value>`. Sidecars beat xattrs (not portable
      across Linux/macOS) and beat an H2/SQLite index (overkill for
      single-tenant single-instance).
    
      **Atomicity**: every `put` writes to a `.tmp` sibling file and
      atomic-moves it into place so a concurrent read against the same
      key never sees a half-written mix.
    
      **Key safety**: `put`/`get`/`delete` all validate the key:
      rejects blank, leading `/`, `..` (path traversal), and trailing
      `.meta` (sidecar collision). Every resolved path is checked to
      stay under the configured root via `normalize().startsWith(root)`.
    
    - `FileController` at `/api/v1/files/**`:
        * `POST   /api/v1/files?key=...`            multipart upload (form field `file`)
        * `GET    /api/v1/files?prefix=...`         list by prefix
        * `GET    /api/v1/files/metadata?key=...`   metadata only (doesn't open the stream)
        * `GET    /api/v1/files/download?key=...`   stream bytes with the right Content-Type + filename
        * `DELETE /api/v1/files?key=...`            delete by key
      All endpoints @RequirePermission-gated via the keys declared in
      the metadata YAML. The `key` is a query parameter, NOT a path
      variable, so slashes in the key don't collide with Spring's path
      matching.
    
    - `META-INF/vibe-erp/metadata/files.yml` — 2 permissions + 1 menu.
    
    ## Smoke test (fresh DB, as admin)
    
    ```
    POST /api/v1/files?key=reports/smoke-test.txt  (multipart file)
      → 201 {"key":"reports/smoke-test.txt",
             "size":61,
             "contentType":"text/plain",
             "createdAt":"...","updatedAt":"..."}
    
    GET  /api/v1/files?prefix=reports/
      → [{"key":"reports/smoke-test.txt","size":61, ...}]
    
    GET  /api/v1/files/metadata?key=reports/smoke-test.txt
      → same handle, no bytes
    
    GET  /api/v1/files/download?key=reports/smoke-test.txt
      → 200 Content-Type: text/plain
         body: original upload content  (diff == 0)
    
    DELETE /api/v1/files?key=reports/smoke-test.txt
      → 200 {"removed":true}
    
    GET  /api/v1/files/download?key=reports/smoke-test.txt
      → 404
    
    # path traversal
    POST /api/v1/files?key=../escape  (multipart file)
      → 400 "file key must not contain '..' (got '../escape')"
    
    GET  /api/v1/_meta/metadata
      → permissions include ["files.file.read", "files.file.write"]
    ```
    
    Downloaded bytes match the uploaded bytes exactly — round-trip
    verified with `diff -q`.
    
    ## Tests
    
    - 12 new unit tests in `LocalDiskFileStorageTest` using JUnit 5's
      `@TempDir`:
      * `put then get round-trips content and metadata`
      * `put overwrites an existing key with the new content`
      * `get returns null for an unknown key`
      * `exists distinguishes present from absent`
      * `delete removes the file and its metadata sidecar`
      * `delete on unknown key returns false`
      * `list filters by prefix and returns sorted keys`
      * `put rejects a key with dot-dot`
      * `put rejects a key starting with slash`
      * `put rejects a key ending in dot-meta sidecar`
      * `put rejects blank content type`
      * `list sidecar metadata files are hidden from listing results`
    - Total framework unit tests: 327 (was 315), all green.
    
    ## What this unblocks
    
    - **P1.8 JasperReports integration** — now has a first-class home
      for generated PDFs. A report renderer can call
      `fileStorage.put("reports/quote-$code.pdf", "application/pdf", ...)`
      and return the handle to the caller.
    - **Plug-in attachments** — the printing-shop plug-in's future
      "plate scan image" or "QC report" attachments can be stored via
      `context.files` without touching the database.
    - **Export/import flows** — a scheduled job can write a nightly
      CSV export via `FileStorage.put` and a separate endpoint can
      download it; the scheduler-to-storage path is clean and typed.
    - **S3 backend when needed** — the interface is already streaming-
      based; dropping in an `S3FileStorage` @Component and toggling
      `vibeerp.files.backend: s3` in config is a future additive chunk,
      zero api.v1 churn.
    
    ## Non-goals (parking lot)
    
    - S3 backend. The config already reads `vibeerp.files.backend`,
      local is hard-wired for v1.0. Keeps the dependency tree off
      aws-sdk until a real consumer exists.
    - Range reads / HTTP `Range: bytes=...` support. Future
      enhancement for large-file streaming (e.g. video attachments).
    - Presigned URLs (for direct browser-to-S3 upload, skipping the
      framework). Design decision lives with the S3 backend chunk.
    - Per-file ACLs. The four `files.file.*` permissions currently
      gate all files uniformly; per-path or per-owner ACLs would
      require a new metadata table and haven't been asked for by any
      PBC yet.
    - Plug-in loader integration. `PluginContext.files` throws the
      default `UnsupportedOperationException` until the plug-in
      loader is wired to pass the host `FileStorage` through
      `DefaultPluginContext`. Lands in the same chunk as the first
      plug-in that needs to store a file.
    zichun authored
     
    Browse Code »
  • Closes the P1.10 row of the implementation plan. New platform-jobs
    subproject shipping a Quartz-backed background job engine adapted
    to the api.v1 JobHandler contract, so PBCs and plug-ins can register
    scheduled work without ever importing Quartz types.
    
    ## The shape (matches the P2.1 workflow engine)
    
    platform-jobs is to scheduled work what platform-workflow is to
    BPMN service tasks. Same pattern, same discipline:
    
     - A single `@Component` bridge (`QuartzJobBridge`) is the ONLY
       org.quartz.Job implementation in the framework. Every persistent
       trigger points at it.
     - A single `JobHandlerRegistry` (owner-tagged, duplicate-key-rejecting,
       ConcurrentHashMap-backed) holds every registered JobHandler by key.
       Mirrors `TaskHandlerRegistry`.
     - The bridge reads the handler key from the trigger's JobDataMap,
       looks it up in the registry, and executes the matching JobHandler
       inside a `PrincipalContext.runAs("system:jobs:<key>")` block so
       audit rows written during the job get a structured, greppable
       `created_by` value ("system:jobs:core.audit.prune") instead of
       the default `__system__`.
     - Handler-thrown exceptions are re-wrapped as `JobExecutionException`
       so Quartz's MISFIRE machinery handles them properly.
     - `@DisallowConcurrentExecution` on the bridge stops a long-running
       handler from being started again before it finishes.
    
    ## api.v1 additions (package `org.vibeerp.api.v1.jobs`)
    
     - `JobHandler` — interface with `key()` + `execute(context)`.
       Analogous to the workflow TaskHandler. Plug-ins implement this
       to contribute scheduled work without any Quartz dependency.
     - `JobContext` — read-only execution context passed to the handler:
       principal, locale, correlation id, started-at instant, data map.
       Unlike TaskContext it has no `set()` writeback — scheduled jobs
       don't produce continuation state for a downstream step; a job
       that wants to talk to the rest of the system writes to its own
       domain table or publishes an event.
     - `JobScheduler` — injectable facade exposing:
         * `scheduleCron(scheduleKey, handlerKey, cronExpression, data)`
         * `scheduleOnce(scheduleKey, handlerKey, runAt, data)`
         * `unschedule(scheduleKey): Boolean`
         * `triggerNow(handlerKey, data): JobExecutionSummary`
           — synchronous in-thread execution, bypasses Quartz; used by
           the HTTP trigger endpoint and by tests.
         * `listScheduled(): List<ScheduledJobInfo>` — introspection
       Both `scheduleCron` and `scheduleOnce` are idempotent on
       `scheduleKey` (replace if exists).
     - `ScheduledJobInfo` + `JobExecutionSummary` + `ScheduleKind` —
       read-only DTOs returned by the scheduler.
    
    ## platform-jobs runtime
    
     - `QuartzJobBridge` — the shared Job impl. Routes by the
       `__vibeerp_handler_key` JobDataMap entry. Uses `@Autowired` field
       injection because Quartz instantiates Job classes through its
       own JobFactory (Spring Boot's `SpringBeanJobFactory` autowires
       fields after construction, which is the documented pattern).
     - `QuartzJobScheduler` — the concrete api.v1 `JobScheduler`
       implementation. Builds JobDetail + Trigger pairs under fixed
       group names (`vibeerp-jobs`), uses `addJob(replace=true)` +
       explicit `checkExists` + `rescheduleJob` for idempotent
       scheduling, strips the reserved `__vibeerp_handler_key` from the
       data visible to the handler.
     - `SimpleJobContext` — internal immutable `JobContext` impl.
       Defensive-copies the data map at construction.
     - `JobHandlerRegistry` — owner-tagged registry (OWNER_CORE by
       default, any other string for plug-in ownership). Same
       `register` / `unregister` / `unregisterAllByOwner` / `find` /
       `keys` / `size` surface as `TaskHandlerRegistry`. The plug-in
       loader integration seam is defined; the loader hook that calls
       `register(handler, pluginId)` lands when a plug-in actually ships
       a job handler (YAGNI).
     - `JobController` at `/api/v1/jobs/**`:
         * `GET  /handlers`                (perm `jobs.handler.read`)
         * `POST /handlers/{key}/trigger`  (perm `jobs.job.trigger`)
         * `GET  /scheduled`               (perm `jobs.schedule.read`)
         * `POST /scheduled`               (perm `jobs.schedule.write`)
         * `DELETE /scheduled/{key}`       (perm `jobs.schedule.write`)
     - `VibeErpPingJobHandler` — built-in diagnostic. Key
       `vibeerp.jobs.ping`. Logs the invocation and exits. Safe to
       trigger from any environment; mirrors the core
       `vibeerp.workflow.ping` workflow handler from P2.1.
     - `META-INF/vibe-erp/metadata/jobs.yml` — 4 permissions + 2 menus.
    
    ## Spring Boot config (application.yaml)
    
    ```
    spring.quartz:
      job-store-type: jdbc
      jdbc:
        initialize-schema: always     # creates QRTZ_* tables on first boot
      properties:
        org.quartz.scheduler.instanceName: vibeerp-scheduler
        org.quartz.scheduler.instanceId: AUTO
        org.quartz.threadPool.threadCount: "4"
        org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
        org.quartz.jobStore.isClustered: "false"
    ```
    
    ## The config trap caught during smoke-test (documented in-file)
    
    First boot crashed with `SchedulerConfigException: DataSource name
    not set.` because I'd initially added
    `org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX`
    to the raw Quartz properties. That is correct for a standalone
    Quartz deployment but WRONG for the Spring Boot starter: the
    starter configures a `LocalDataSourceJobStore` that wraps the
    Spring-managed DataSource automatically when `job-store-type=jdbc`,
    and setting `jobStore.class` explicitly overrides that wrapper back
    to Quartz's standalone JobStoreTX — which then fails at init
    because Quartz-standalone expects a separately-named `dataSource`
    property the Spring Boot starter doesn't supply. Fix: drop the
    `jobStore.class` property entirely. The `driverDelegateClass` is
    still fine to set explicitly because it's read by both the standalone
    and Spring-wrapped JobStore implementations. Rationale is documented
    in the config comment so the next maintainer doesn't add it back.
    
    ## Smoke test (fresh DB, as admin)
    
    ```
    GET  /api/v1/jobs/handlers
      → {"count": 1, "keys": ["vibeerp.jobs.ping"]}
    
    POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger
         {"data": {"source": "smoke-test"}}
      → 200 {"handlerKey": "vibeerp.jobs.ping",
             "correlationId": "e142...",
             "startedAt": "...",
             "finishedAt": "...",
             "ok": true}
      log: VibeErpPingJobHandler invoked at=... principal='system:jobs:manual-trigger'
           data={source=smoke-test}
    
    GET  /api/v1/jobs/scheduled → []
    
    POST /api/v1/jobs/scheduled
         {"scheduleKey": "ping-every-sec",
          "handlerKey": "vibeerp.jobs.ping",
          "cronExpression": "0/1 * * * * ?",
          "data": {"trigger": "cron"}}
      → 201 {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping"}
    
    # after 3 seconds
    GET  /api/v1/jobs/scheduled
      → [{"scheduleKey": "ping-every-sec",
          "handlerKey": "vibeerp.jobs.ping",
          "kind": "CRON",
          "cronExpression": "0/1 * * * * ?",
          "nextFireTime": "...",
          "previousFireTime": "...",
          "data": {"trigger": "cron"}}]
    
    DELETE /api/v1/jobs/scheduled/ping-every-sec → 200 {"removed": true}
    
    # handler log count after ~3 seconds of cron ticks
    grep -c "VibeErpPingJobHandler invoked" /tmp/boot.log → 5
    # 1 manual trigger + 4 cron ticks before unschedule — matches the
    # 0/1 * * * * ? expression
    
    # negatives
    POST /api/v1/jobs/handlers/nope/trigger
      → 400 "no JobHandler registered for key 'nope'"
    POST /api/v1/jobs/scheduled  {cronExpression: "not a cron"}
      → 400 "invalid Quartz cron expression: 'not a cron'"
    ```
    
    ## Three schemas coexist in one Postgres database
    
    ```
    SELECT count(*) FILTER (WHERE table_name LIKE 'qrtz_%')    AS quartz_tables,
           count(*) FILTER (WHERE table_name LIKE 'act_%')     AS flowable_tables,
           count(*) FILTER (WHERE table_name NOT LIKE 'qrtz_%'
                              AND table_name NOT LIKE 'act_%'
                              AND table_schema = 'public')     AS vibeerp_tables
    FROM information_schema.tables WHERE table_schema = 'public';
    
     quartz_tables | flowable_tables | vibeerp_tables
    ---------------+-----------------+----------------
                11 |              39 |             48
    ```
    
    Three independent schema owners (Quartz / Flowable / Liquibase) in
    one public schema, no collisions. Spring Boot's
    `QuartzDataSourceScriptDatabaseInitializer` runs the QRTZ_* DDL
    once and skips on subsequent boots; Flowable's internal MyBatis
    schema manager does the same for ACT_* tables; our Liquibase owns
    the rest.
    
    ## Tests
    
    - 6 new tests in `JobHandlerRegistryTest`:
      * initial handlers registered with OWNER_CORE
      * duplicate key fails fast with both owners in the error
      * unregisterAllByOwner only removes handlers owned by that id
      * unregister by key returns false for unknown
      * find on missing key returns null
      * blank key is rejected
    - 9 new tests in `QuartzJobSchedulerTest` (Quartz Scheduler mocked):
      * scheduleCron rejects an unknown handler key
      * scheduleCron rejects an invalid cron expression
      * scheduleCron adds job + schedules trigger when nothing exists yet
      * scheduleCron reschedules when the trigger already exists
      * scheduleOnce uses a simple trigger at the requested instant
      * unschedule returns true/false correctly
      * triggerNow calls the handler synchronously and returns ok=true
      * triggerNow propagates the handler's exception
      * triggerNow rejects an unknown handler key
    - Total framework unit tests: 315 (was 300), all green.
    
    ## What this unblocks
    
    - **pbc-finance audit prune** — a core recurring job that deletes
      posted journal entries older than N days, driven by a cron from
      a Tier 1 metadata row.
    - **Plug-in scheduled work** — once the loader integration hook is
      wired (trivial follow-up), any plug-in's `start(context)` can
      register a JobHandler via `context.jobs.register(handler)` and
      the host strips it on plug-in stop via `unregisterAllByOwner`.
    - **Delayed workflow continuations** — a BPMN handler can call
      `jobScheduler.scheduleOnce(...)` to "re-evaluate this workflow
      in 24 hours if no one has approved it", bridging the workflow
      engine and the scheduler without introducing Thread.sleep.
    - **Outbox draining strategy** — the existing 5-second OutboxPoller
      can move from a Spring @Scheduled to a Quartz cron so it
      inherits the scheduler's persistence, misfire handling, and the
      future clustering story.
    
    ## Non-goals (parking lot)
    
    - **Clustered scheduling.** `isClustered=false` for now. Making
      this true requires every instance to share a unique `instanceId`
      and agree on the JDBC lock policy — doable but out of v1.0 scope
      since vibe_erp is single-tenant single-instance by design.
    - **Async execution of triggerNow.** The current `triggerNow` runs
      synchronously on the caller thread so HTTP requests see the real
      result. A future "fire and forget" endpoint would delegate to
      `Scheduler.triggerJob(...)` against the JobDetail instead.
    - **Per-job permissions.** Today the four `jobs.*` permissions gate
      the whole controller. A future enhancement could attach
      per-handler permissions (so "trigger audit prune" requires a
      different permission than "trigger pricing refresh").
    - **Plug-in loader integration.** The seam is defined on
      `JobHandlerRegistry` (owner tagging + unregisterAllByOwner) but
      `VibeErpPluginManager` doesn't call it yet. Lands in the same
      chunk as the first plug-in that ships a JobHandler.
    zichun authored
     
    Browse Code »
  • Completes the plug-in side of the embedded Flowable story. The P2.1
    core made plug-ins able to register TaskHandlers; this chunk makes
    them able to ship the BPMN processes those handlers serve.
    
    ## Why Flowable's built-in auto-deployer couldn't do it
    
    Flowable's Spring Boot starter scans the host classpath at engine
    startup for `classpath[*]:/processes/[*].bpmn20.xml` and auto-deploys
    every hit (the literal glob is paraphrased because the Kotlin KDoc
    comment below would otherwise treat the embedded slash-star as the
    start of a nested comment — feedback memory "Kotlin KDoc nested-
    comment trap"). PF4J plug-ins load through an isolated child
    classloader that is NOT visible to that scan, so a `processes/*.bpmn20.xml`
    resource shipped inside a plug-in JAR is never seen. This chunk adds
    a dedicated host-side deployer that opens each plug-in JAR file
    directly (same JarFile walk pattern as
    `MetadataLoader.loadFromPluginJar`) and hand-registers the BPMNs
    with the Flowable `RepositoryService`.
    
    ## Mechanism
    
    ### New PluginProcessDeployer (platform-workflow)
    
    One Spring bean, two methods:
    
    - `deployFromPlugin(pluginId, jarPath): String?` — walks the JAR,
      collects every entry whose name starts with `processes/` and ends
      with `.bpmn20.xml` or `.bpmn`, and bundles the whole set into one
      Flowable `Deployment` named `plugin:<id>` with `category = pluginId`.
      Returns the deployment id or null (missing JAR / no BPMN resources).
      One deployment per plug-in keeps undeploy atomic and makes the
      teardown query unambiguous.
    - `undeployByPlugin(pluginId): Int` — runs
      `createDeploymentQuery().deploymentCategory(pluginId).list()` and
      calls `deleteDeployment(id, cascade=true)` on each hit. Cascading
      removes process instances and history rows along with the
      deployment — "uninstalling a plug-in makes it disappear". Idempotent:
      a second call returns 0.
    
    The deployer reads the JAR entries into byte arrays inside the
    JarFile's `use` block and then passes the bytes to
    `DeploymentBuilder.addBytes(name, bytes)` outside the block, so the
    jar handle is already closed by the time Flowable sees the
    deployment. No input-stream lifetime tangles.
    
    ### VibeErpPluginManager wiring
    
    - New constructor dependency on `PluginProcessDeployer`.
    - Deploy happens AFTER `start(context)` succeeds. The ordering matters
      because a plug-in can only register its TaskHandlers during
      `start(context)`, and a deployed BPMN whose service-task delegate
      expression resolves to a key with no matching handler would still
      deploy (Flowable only resolves delegates at process-start time).
      Registering handlers first is the safer default: the moment the
      deployment lands, every referenced handler is already in the
      TaskHandlerRegistry.
    - BPMN deployment failure AFTER a successful `start(context)` now
      fully unwinds the plug-in state: call `instance.stop()`, remove
      the plug-in from the `started` list, strip its endpoints + its
      TaskHandlers + call `undeployByPlugin` (belt and suspenders — the
      deploy attempt may have partially succeeded). That mirrors the
      existing start-failure unwinding so the framework doesn't end up
      with a plug-in that's half-installed after any step throws.
    - `destroy()` calls `undeployByPlugin(pluginId)` alongside the
      existing `unregisterAllByOwner(pluginId)`.
    
    ### Reference plug-in BPMN
    
    `reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml`
    — a minimal two-task process (`start` → serviceTask → `end`) whose
    serviceTask id is `printing_shop.plate.approve`, matching the
    PlateApprovalTaskHandler key landed in the previous commit. Process
    definition key is `plugin-printing-shop-plate-approval` (distinct
    from the serviceTask id because BPMN 2.0 requires element ids to be
    unique per document — same separation used for the core ping
    process).
    
    ## Smoke test (fresh DB, plug-in staged)
    
    ```
    $ docker compose down -v && docker compose up -d db
    $ ./gradlew :distribution:bootRun &
    ...
    registered TaskHandler 'vibeerp.workflow.ping' owner='core' ...
    TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
    ...
    plug-in 'printing-shop' Liquibase migrations applied successfully
    [plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
    registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
    [plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
    PluginProcessDeployer: plug-in 'printing-shop' deployed 1 BPMN resource(s) as Flowable deploymentId='4e9f...': [processes/plate-approval.bpmn20.xml]
    
    $ curl /api/v1/workflow/definitions (as admin)
    [
      {"key":"plugin-printing-shop-plate-approval",
       "name":"Printing shop — plate approval",
       "version":1,
       "deploymentId":"4e9f85a6-33cf-11f1-acaa-1afab74ef3b4",
       "resourceName":"processes/plate-approval.bpmn20.xml"},
      {"key":"vibeerp-workflow-ping",
       "name":"vibe_erp workflow ping",
       "version":1,
       "deploymentId":"4f48...",
       "resourceName":"vibeerp-ping.bpmn20.xml"}
    ]
    
    $ curl -X POST /api/v1/workflow/process-instances
             {"processDefinitionKey":"plugin-printing-shop-plate-approval",
              "variables":{"plateId":"PLATE-007"}}
      → {"processInstanceId":"5b1b...",
         "ended":true,
         "variables":{"plateId":"PLATE-007",
                      "plateApproved":true,
                      "approvedBy":"user:admin",
                      "approvedAt":"2026-04-09T04:48:30.514523Z"}}
    
    $ kill -TERM <pid>
    [ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
    [ionShutdownHook] PluginProcessDeployer: plug-in 'printing-shop' deployment '4e9f...' removed (cascade)
    ```
    
    Full end-to-end loop closed: plug-in ships a BPMN → host reads it
    out of the JAR → Flowable deployment registered under the plug-in
    category → HTTP caller starts a process instance via the standard
    `/api/v1/workflow/process-instances` surface → dispatcher routes by
    activity id to the plug-in's TaskHandler → handler writes output
    variables + plug-in sees the authenticated caller as `ctx.principal()`
    via the reserved `__vibeerp_*` process-variable propagation from
    commit `ef9e5b42`. SIGTERM cleanly undeploys the plug-in's BPMNs.
    
    ## Tests
    
    - 6 new unit tests on `PluginProcessDeployerTest`:
      * `deployFromPlugin returns null when jarPath is not a regular file`
        — guard against dev-exploded plug-in dirs
      * `deployFromPlugin returns null when the plug-in jar has no BPMN resources`
      * `deployFromPlugin reads every bpmn resource under processes and
        deploys one bundle` — builds a real temporary JAR with two BPMN
        entries + a README + a metadata YAML, verifies that both BPMNs
        go through `addBytes` with the right names and the README /
        metadata entries are skipped
      * `deployFromPlugin rejects a blank plug-in id`
      * `undeployByPlugin returns zero when there is nothing to remove`
      * `undeployByPlugin cascades a deleteDeployment per matching deployment`
    - Total framework unit tests: 275 (was 269), all green.
    
    ## Kotlin trap caught during authoring (feedback memory paid out)
    
    First compile failed with `Unclosed comment` on the last line of
    `PluginProcessDeployer.kt`. The culprit was a KDoc paragraph
    containing the literal glob
    `classpath*:/processes/*.bpmn20.xml`: the embedded `/*` inside the
    backtick span was parsed as the start of a nested block comment
    even though the surrounding `/* ... */` KDoc was syntactically
    complete. The saved feedback-memory entry "Kotlin KDoc nested-comment
    trap" covered exactly this situation — the fix is to spell out glob
    characters as `[star]` / `[slash]` (or the word "slash-star") inside
    documentation so the literal `/*` never appears. The KDoc now
    documents the behaviour AND the workaround so the next maintainer
    doesn't hit the same trap.
    
    ## Non-goals (still parking lot)
    
    - Handler-side access to the full PluginContext — PlateApprovalTaskHandler
      is still a pure function because the framework doesn't hand
      TaskHandlers a context object. For REF.1 (real quote→job-card)
      handlers will need to read + mutate plug-in-owned tables; the
      cleanest approach is closure-capture inside the plug-in class
      (handler instantiated inside `start(context)` with the context
      captured in the outer scope). Decision deferred to REF.1.
    - BPMN resource hot reload. The deployer runs once per plug-in
      start; a plug-in whose BPMN changes under its feet at runtime
      isn't supported yet.
    - Plug-in-shipped DMN / CMMN resources. The deployer only looks at
      `.bpmn20.xml` and `.bpmn`. Decision-table and case-management
      resources are not on the v1.0 critical path.
    zichun authored
     
    Browse Code »
  • ## What's new
    
    Plug-ins can now contribute workflow task handlers to the framework.
    The P2.1 `TaskHandlerRegistry` only saw `@Component` TaskHandler beans
    from the host Spring context; handlers defined inside a PF4J plug-in
    were invisible because the plug-in's child classloader is not in the
    host's bean list. This commit closes that gap.
    
    ## Mechanism
    
    ### api.v1
    
    - New interface `org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar`
      with a single `register(handler: TaskHandler)` method. Plug-ins call
      it from inside their `start(context)` lambda.
    - `PluginContext.taskHandlers: PluginTaskHandlerRegistrar` — added as
      a new optional member with a default implementation that throws
      `UnsupportedOperationException("upgrade to v0.7 or later")`, so
      pre-existing plug-in jars remain binary-compatible with the new
      host and a plug-in built against v0.7 of the api-v1 surface fails
      fast on an old host instead of silently doing nothing. Same
      pattern we used for `endpoints` and `jdbc`.
    
    ### platform-workflow
    
    - `TaskHandlerRegistry` gains owner tagging. Every registered handler
      now carries an `ownerId`: core `@Component` beans get
      `TaskHandlerRegistry.OWNER_CORE = "core"` (auto-assigned through
      the constructor-injection path), plug-in-contributed handlers get
      their PF4J plug-in id. New API:
      * `register(handler, ownerId = OWNER_CORE)` (default keeps existing
        call sites unchanged)
      * `unregisterAllByOwner(ownerId): Int` — strip every handler owned
        by that id in one call, returns the count for log correlation
      * The duplicate-key error message now includes both owners so a
        plug-in trying to stomp on a core handler gets an actionable
        "already registered by X (owner='core'), attempted by Y
        (owner='printing-shop')" instead of "already registered".
      * Internal storage switched from `ConcurrentHashMap<String, TaskHandler>`
        to `ConcurrentHashMap<String, Entry>` where `Entry` carries
        `(handler, ownerId)`. `find(key)` still returns `TaskHandler?`
        so the dispatcher is unchanged.
    - No behavioral change for the hot-path (`DispatchingJavaDelegate`) —
      only the registration/teardown paths changed.
    
    ### platform-plugins
    
    - New dependency on `:platform:platform-workflow` (the only new inter-
      module dep of this chunk; it is the module that exposes
      `TaskHandlerRegistry`).
    - New internal class `ScopedTaskHandlerRegistrar(hostRegistry, pluginId)`
      that implements the api.v1 `PluginTaskHandlerRegistrar` by delegating
      `register(handler)` to `hostRegistry.register(handler, ownerId =
      pluginId)`. Constructed fresh per plug-in by `VibeErpPluginManager`,
      so the plug-in never sees (or can tamper with) the owner id.
    - `DefaultPluginContext` gains a `scopedTaskHandlers` constructor
      parameter and exposes it as the `PluginContext.taskHandlers`
      override.
    - `VibeErpPluginManager`:
      * injects `TaskHandlerRegistry`
      * constructs `ScopedTaskHandlerRegistrar(registry, pluginId)` per
        plug-in when building `DefaultPluginContext`
      * partial-start failure now also calls
        `taskHandlerRegistry.unregisterAllByOwner(pluginId)`, matching
        the existing `endpointRegistry.unregisterAll(pluginId)` cleanup
        so a throwing `start(context)` cannot leave stale registrations
      * `destroy()` calls the same `unregisterAllByOwner` for every
        started plug-in in reverse order, mirroring the endpoint cleanup
    
    ### reference-customer/plugin-printing-shop
    
    - New file `workflow/PlateApprovalTaskHandler.kt` — the first plug-in-
      contributed TaskHandler in the framework. Key
      `printing_shop.plate.approve`. Reads a `plateId` process variable,
      writes `plateApproved`, `plateId`, `approvedBy` (principal label),
      `approvedAt` (ISO instant) and exits. No DB mutation yet: a proper
      plate-approval handler would UPDATE `plugin_printingshop__plate` via
      `context.jdbc`, but that requires handing the TaskHandler a
      projection of the PluginContext — a deliberate non-goal of this
      chunk, deferred to the "handler context" follow-up.
    - `PrintingShopPlugin.start(context)` now ends with
      `context.taskHandlers.register(PlateApprovalTaskHandler())` and logs
      the registration.
    - Package layout: `org.vibeerp.reference.printingshop.workflow` is
      the plug-in's workflow namespace going forward (the next printing-
      shop handlers for REF.1 — quote-to-job-card, job-card-to-work-order
      — will live alongside).
    
    ## Smoke test (fresh DB, plug-in staged)
    
    ```
    $ docker compose down -v && docker compose up -d db
    $ ./gradlew :distribution:bootRun &
    ...
    TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
    ...
    plug-in 'printing-shop' Liquibase migrations applied successfully
    vibe_erp plug-in loaded: id=printing-shop version=0.1.0-SNAPSHOT state=STARTED
    [plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
    registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' class='org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler'
    [plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
    
    $ curl /api/v1/workflow/handlers (as admin)
    {
      "count": 2,
      "keys": ["printing_shop.plate.approve", "vibeerp.workflow.ping"]
    }
    
    $ curl /api/v1/plugins/printing-shop/ping  # plug-in HTTP still works
    {"plugin":"printing-shop","ok":true,"version":"0.1.0-SNAPSHOT", ...}
    
    $ curl -X POST /api/v1/workflow/process-instances
             {"processDefinitionKey":"vibeerp-workflow-ping"}
      (principal propagation from previous commit still works — pingedBy=user:admin)
    
    $ kill -TERM <pid>
    [ionShutdownHook] vibe_erp stopping 1 plug-in(s)
    [ionShutdownHook] [plugin:printing-shop] printing-shop plug-in stopped
    [ionShutdownHook] unregistered TaskHandler 'printing_shop.plate.approve' (owner stopped)
    [ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
    ```
    
    Every expected lifecycle event fires in the right order with the
    right owner attribution. Core handlers are untouched by plug-in
    teardown.
    
    ## Tests
    
    - 4 new / updated tests on `TaskHandlerRegistryTest`:
      * `unregisterAllByOwner only removes handlers owned by that id`
        — 2 core + 2 plug-in, unregister the plug-in owner, only the
        2 plug-in keys are removed
      * `unregisterAllByOwner on unknown owner returns zero`
      * `register with blank owner is rejected`
      * Updated `duplicate key fails fast` to assert the new error
        message format including both owner ids
    - Total framework unit tests: 269 (was 265), all green.
    
    ## What this unblocks
    
    - **REF.1** (real printing-shop quote→job-card workflow) can now
      register its production handlers through the same seam
    - **Plug-in-contributed handlers with state access** — the next
      design question is how a plug-in handler gets at the plug-in's
      database and translator. Two options: pass a projection of the
      PluginContext through TaskContext, or keep a reference to the
      context captured at plug-in start (closure). The PlateApproval
      handler in this chunk is pure on purpose to keep the seam
      conversation separate.
    - **Plug-in-shipped BPMN auto-deployment** — Flowable's default
      classpath scan uses `classpath*:/processes/*.bpmn20.xml` which
      does NOT see PF4J plug-in classloaders. A dedicated
      `PluginProcessDeployer` that walks each started plug-in's JAR for
      BPMN resources and calls `repositoryService.createDeployment` is
      the natural companion to this commit, still pending.
    
    ## Non-goals (still parking lot)
    
    - BPMN processes shipped inside plug-in JARs (see above — needs
      its own chunk, because it requires reading resources from the
      PF4J classloader and constructing a Flowable deployment by hand)
    - Per-handler permission checks — a handler that wants a permission
      gate still has to call back through its own context; P4.3's
      @RequirePermission aspect doesn't reach into Flowable delegate
      execution.
    - Hot reload of a running plug-in's TaskHandlers. The seam supports
      it, but `unloadPlugin` + `loadPlugin` at runtime isn't exercised
      by any current caller.
    zichun authored
     
    Browse Code »
  • Before this commit, every TaskHandler saw a fixed `workflow-engine`
    System principal via `ctx.principal()` because there was no plumbing
    from the REST caller down to the dispatcher. A printing-shop
    quote-to-job-card handler (or any real business workflow) needs to
    know the actual user who kicked off the process so audit columns and
    role-based logic behave correctly.
    
    ## Mechanism
    
    The chain is: Spring Security populates `SecurityContextHolder` →
    `PrincipalContextFilter` mirrors it into `AuthorizationContext`
    (already existed) → `WorkflowService.startProcess` reads the bound
    `AuthorizedPrincipal` and stashes two reserved process variables
    (`__vibeerp_initiator_id`, `__vibeerp_initiator_username`) before
    calling `RuntimeService.startProcessInstanceByKey` →
    `DispatchingJavaDelegate` reads them back off each `DelegateExecution`
    when constructing the `DelegateTaskContext` → handler sees a real
    `Principal.User` from `ctx.principal()`.
    
    When the process is started outside an HTTP request (e.g. a future
    Quartz-scheduled process, or a signal fired by a PBC event
    subscriber), `AuthorizationContext.current()` is null, no initiator
    variables are written, and the dispatcher falls back to the
    `Principal.System("workflow-engine")` principal. A corrupt initiator
    id (e.g. a non-UUID string) also falls back to the system principal
    rather than failing the task, so a stale variable can't brick a
    running workflow.
    
    ## Reserved variable hygiene
    
    The `__vibeerp_` prefix is reserved framework plumbing. Two
    consequences wired in this commit:
    
    - `DispatchingJavaDelegate` strips keys starting with `__vibeerp_`
      from the variable snapshot handed to the handler (via
      `WorkflowTask.variables`), so handler code cannot accidentally
      depend on the initiator id through the wrong door — it must use
      `ctx.principal()`.
    - `WorkflowService.startProcess` and `getInstanceVariables` strip
      the same prefix from their HTTP response payloads so REST callers
      never see the plumbing either.
    
    The prefix constant lives on `DispatchingJavaDelegate.RESERVED_VAR_PREFIX`
    so there is exactly one source of truth. The two initiator variable
    names are public constants on `WorkflowService` — tests, future
    plug-in code, and any custom handlers that genuinely need the raw
    ids (e.g. a security-audit task) can depend on the stable symbols
    instead of hard-coded strings.
    
    ## PingTaskHandler as the executable witness
    
    `PingTaskHandler` now writes a `pingedBy` output variable with a
    principal label (`user:<username>`, `system:<name>`, or
    `plugin:<pluginId>`) and logs it. That makes the end-to-end smoke
    test trivially assertable:
    
    ```
    POST /api/v1/workflow/process-instances
         {"processDefinitionKey":"vibeerp-workflow-ping"}
      (as admin user, with valid JWT)
      → {"processInstanceId": "...", "ended": true,
         "variables": {
           "pong": true,
           "pongAt": "...",
           "correlationId": "...",
           "pingedBy": "user:admin"
         }}
    ```
    
    Note the RESPONSE does NOT contain `__vibeerp_initiator_id` or
    `__vibeerp_initiator_username` — the reserved-var filter in the
    service layer hides them. The handler-side log line confirms
    `principal='user:admin'` in the service-task execution thread.
    
    ## Tests
    
    - 3 new tests in `DispatchingJavaDelegateTest`:
      * `resolveInitiator` returns a User principal when both vars set
      * falls back to system principal when id var is missing
      * falls back to system principal when id var is corrupt
        (non-UUID string)
    - Updated `variables given to the handler are a defensive copy` to
      also assert that reserved `__vibeerp_*` keys are stripped from
      the task's variable snapshot.
    - Updated `PingTaskHandlerTest`:
      * rename to "writes pong plus timestamp plus correlation id plus
        user principal label"
      * new test for the System-principal branch producing
        `pingedBy=system:workflow-engine`
    - Total framework unit tests: 265 (was 261), all green.
    
    ## Non-goals (still parking lot)
    
    - Plug-in-contributed TaskHandler registration via the PF4J loader
      walking child contexts for TaskHandler beans and calling
      `TaskHandlerRegistry.register`. The seam exists on the registry;
      the loader integration is the next chunk, and unblocks REF.1.
    - Propagation of the full role set (not just id+username) into the
      TaskContext. Handlers don't currently see the initiator's roles.
      Can be added as a third reserved variable when a handler actually
      needs it — YAGNI for now.
    - BPMN user tasks / signals / timers — engine supports them but we
      have no HTTP surface for them yet.
    zichun authored
     
    Browse Code »
  • New platform subproject `platform/platform-workflow` that makes
    `org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This
    is the framework's first chunk of Phase 2 (embedded workflow engine)
    and the dependency other work has been waiting on — pbc-production
    routings/operations, the full buy-make-sell BPMN scenario in the
    reference plug-in, and ultimately the BPMN designer web UI all hang
    off this seam.
    
    ## The shape
    
    - `flowable-spring-boot-starter-process:7.0.1` pulled in behind a
      single new module. Every other module in the framework still sees
      only the api.v1 TaskHandler + WorkflowTask + TaskContext surface —
      guardrail #10 stays honest, no Flowable type leaks to plug-ins or
      PBCs.
    - `TaskHandlerRegistry` is the host-side index of every registered
      handler, keyed by `TaskHandler.key()`. Auto-populated from every
      Spring bean implementing TaskHandler via constructor injection of
      `List<TaskHandler>`; duplicate keys fail fast at registration time.
      `register` / `unregister` exposed for a future plug-in lifecycle
      integration.
    - `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate
      named `taskDispatcher`. Every BPMN service task in the framework
      references it via `flowable:delegateExpression="${taskDispatcher}"`.
      The dispatcher reads `execution.currentActivityId` as the task key
      (BPMN `id` attribute = TaskHandler key — no extension elements, no
      field injection, no second source of truth) and routes to the
      matching registered handler. A defensive copy of the execution
      variables is passed to the handler so it cannot mutate Flowable's
      internal map.
    - `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the
      api.v1 `TaskContext` — the variable `set(name, value)` call
      forwards through Flowable's variable scope (persisted in the same
      transaction as the surrounding service task execution) and null
      values remove the variable. Principal + locale are documented
      placeholders for now (a workflow-engine `Principal.System`),
      waiting on the propagation chunk that plumbs the initiating user
      through `runtimeService.startProcessInstanceByKey(...)`.
    - `WorkflowService` is a thin facade over Flowable's `RuntimeService`
      + `RepositoryService` exposing exactly the four operations the
      controller needs: start, list active, inspect variables, list
      definitions. Everything richer (signals, timers, sub-processes,
      user-task completion, history queries) lands on this seam in later
      chunks.
    - `WorkflowController` at `/api/v1/workflow/**`:
      * `POST /process-instances`                       (permission `workflow.process.start`)
      * `GET  /process-instances`                       (`workflow.process.read`)
      * `GET  /process-instances/{id}/variables`        (`workflow.process.read`)
      * `GET  /definitions`                             (`workflow.definition.read`)
      * `GET  /handlers`                                (`workflow.definition.read`)
      Exception handlers map `NoSuchElementException` +
      `FlowableObjectNotFoundException` → 404, `IllegalArgumentException`
      → 400, and any other `FlowableException` → 400. Permissions are
      declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded
      by the core MetadataLoader so they show up under
      `GET /api/v1/_meta/metadata` alongside every other permission.
    
    ## The executable self-test
    
    - `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module
      classpath and Flowable's starter auto-deploys it at boot.
      Structure: `start` → serviceTask id=`vibeerp.workflow.ping`
      (delegateExpression=`${taskDispatcher}`) → `end`. Process
      definitionKey is `vibeerp-workflow-ping` (distinct from the
      serviceTask id because BPMN 2.0 ids must be unique per document).
    - `PingTaskHandler` is a real shipped bean, not test code: its
      `execute` writes `pong=true`, `pongAt=<Instant.now()>`, and
      `correlationId=<ctx.correlationId()>` to the process variables.
      Operators and AI agents get a trivial "is the workflow engine
      alive?" probe out of the box.
    
    Why the demo lives in src/main, not src/test: Flowable's auto-deployer
    reads from the host classpath at boot, so if either half lived under
    src/test the smoke test wouldn't be reproducible from the shipped
    image — exactly what CLAUDE.md's "reference plug-in is the executable
    acceptance test" discipline is trying to prevent.
    
    ## The Flowable + Liquibase trap
    
    **Learned the hard way during the smoke test.** Adding
    `flowable-spring-boot-starter-process` immediately broke boot with
    `Schema-validation: missing table [catalog__item]`. Liquibase was
    silently not running. Root cause: Flowable 7.x registers a Spring
    Boot `EnvironmentPostProcessor` called
    `FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has
    already set an explicit value, forces
    `spring.liquibase.enabled=false` with a WARN log line that reads
    "Flowable pulls in Liquibase but does not use the Spring Boot
    configuration for it". Our master.xml then never executes and JPA
    validation fails against the empty schema. Fix is a single line in
    `distribution/src/main/resources/application.yaml` —
    `spring.liquibase.enabled: true` — with a comment explaining why it
    must stay there for anyone who touches config next.
    
    Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`,
    etc. tables coexist happily in the same public schema — 39 ACT_*
    tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable
    manages its own schema via its internal MyBatis DDL, Liquibase manages
    ours, they don't touch each other.
    
    ## Smoke-test transcript (fresh DB, dev profile)
    
    ```
    docker compose down -v && docker compose up -d db
    ./gradlew :distribution:bootRun &
    # ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables,
    #     MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler,
    #     BPMN auto-deployed from classpath
    POST /api/v1/auth/login → JWT
    GET  /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping)
    GET  /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]}
    POST /api/v1/workflow/process-instances
         {"processDefinitionKey":"vibeerp-workflow-ping",
          "businessKey":"smoke-1",
          "variables":{"greeting":"ni hao"}}
      → 201 {"processInstanceId":"...","ended":true,
             "variables":{"pong":true,"pongAt":"2026-04-09T...",
                          "correlationId":"...","greeting":"ni hao"}}
    POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"}
      → 404 {"message":"No process definition found for key 'does-not-exist'"}
    GET  /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity)
    ```
    
    ## Tests
    
    - 15 new unit tests in `platform-workflow/src/test`:
      * `TaskHandlerRegistryTest` — init with initial handlers, duplicate
        key fails fast, blank key rejected, unregister removes,
        unregister on unknown returns false, find on missing returns null
      * `DispatchingJavaDelegateTest` — dispatches by currentActivityId,
        throws on missing handler, defensive-copies the variable map
      * `DelegateTaskContextTest` — set non-null forwards, set null
        removes, blank name rejected, principal/locale/correlationId
        passthrough, default correlation id is stable across calls
      * `PingTaskHandlerTest` — key matches the BPMN serviceTask id,
        execute writes pong + pongAt + correlationId
    - Total framework unit tests: 261 (was 246), all green.
    
    ## What this unblocks
    
    - **REF.1** — real quote→job-card workflow handler in the
      printing-shop plug-in
    - **pbc-production routings/operations (v3)** — each operation
      becomes a BPMN step with duration + machine assignment
    - **P2.3** — user-task form rendering (landing on top of the
      RuntimeService already exposed via WorkflowService)
    - **P2.2** — BPMN designer web page (later, depends on R1)
    
    ## Deliberate non-goals (parking lot)
    
    - Principal propagation from the REST caller through the process
      start into the handler — uses a fixed `workflow-engine`
      `Principal.System` for now. Follow-up chunk will plumb the
      authenticated user as a Flowable variable.
    - Plug-in-contributed TaskHandler registration via PF4J child
      contexts — the registry exposes `register/unregister` but the
      plug-in loader doesn't call them yet. Follow-up chunk.
    - BPMN user tasks, signals, timers, history queries — seam exists,
      deliberately not built out.
    - Workflow deployment from `metadata__workflow` rows (the Tier 1
      path). Today deployment is classpath-only via Flowable's auto-
      deployer.
    - The Flowable async job executor is explicitly deactivated
      (`flowable.async-executor-activate: false`) — background-job
      machinery belongs to the future Quartz integration (P1.10), not
      Flowable.
    zichun authored
     
    Browse Code »
  • Removes the ext-handling copy/paste that had grown across four PBCs
    (partners, inventory, orders-sales, orders-purchase). Every service
    that wrote the JSONB `ext` column was manually doing the same
    four-step sequence: validate, null-check, serialize with a local
    ObjectMapper, assign to the entity. And every response mapper was
    doing the inverse: check-if-blank, parse, cast, swallow errors.
    
    Net: ~15 lines saved per PBC, one place to change the ext contract
    later (e.g. PII redaction, audit tagging, field-level events), and
    a stable plug-in opt-in mechanism — any plug-in entity that
    implements `HasExt` automatically participates.
    
    New api.v1 surface:
    
      interface HasExt {
          val extEntityName: String     // key into metadata__custom_field
          var ext: String               // the serialized JSONB column
      }
    
    Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
    entities into the same validation path. Zero Spring/Jackson
    dependencies — api.v1 stays clean.
    
    Extended `ExtJsonValidator` (platform-metadata) with two helpers:
    
      fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
          — null-safe; validates; writes canonical JSON to entity.ext.
            Replaces the validate + writeValueAsString + assign triplet
            in every service's create() and update().
    
      fun parseExt(entity: HasExt): Map<String, Any?>
          — returns empty map on blank/corrupt column; response
            mappers never 500 on bad data. Replaces the four identical
            parseExt local functions.
    
    ExtJsonValidator now takes an ObjectMapper via constructor
    injection (Spring Boot's auto-configured bean).
    
    Entities that now implement HasExt (override val extEntityName;
    override var ext; companion object const val ENTITY_NAME):
      - Partner (`partners.Partner` → "Partner")
      - Location (`inventory.Location` → "Location")
      - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
      - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")
    
    Deliberately NOT converted this chunk:
      - WorkOrder (pbc-production) — its ext column has no declared
        fields yet; a follow-up that adds declarations AND the
        HasExt implementation is cleaner than splitting the two.
      - JournalEntry (pbc-finance) — derived state, no ext column.
    
    Services lose:
      - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
        field (four copies eliminated)
      - The `parseExt(entity): Map` helper function (four copies)
      - The `companion object { const val ENTITY_NAME = ... }` constant
        (moved onto the entity where it belongs)
      - The `val canonicalExt = extValidator.validate(...)` +
        `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
        create pattern (replaced with one applyTo call)
      - The `if (command.ext != null) { ... }` update pattern
        (applyTo is null-safe)
    
    Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
    parseExt (null-safe path, happy path, failure path, blank column,
    round-trip, malformed JSON). Existing service tests just swap the
    mock setup from stubbing `validate` to stubbing `applyTo` and
    `parseExt` with no-ops.
    
    Smoke verified end-to-end against real Postgres:
      - POST /partners with valid ext (partners_credit_limit,
        partners_industry) → 201, canonical form persisted.
      - GET /partners/by-code/X → 200, ext round-trips.
      - POST with invalid enum value → 400 "value 'x' is not in
        allowed set [printing, publishing, packaging, other]".
      - POST with undeclared key → 400 "ext contains undeclared
        key(s) for 'Partner': [rogue_field]".
      - PATCH with new ext → 200, ext updated.
      - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
        applyTo).
      - POST /orders/sales-orders with no ext → 201, the create path
        via the shared helper still works.
    
    246 unit tests (+6 over 240), 18 Gradle subprojects.
    zichun authored
     
    Browse Code »

  • The original test flipped the LAST character of the JWT signature
    segment from 'a' to 'b' (or vice versa). This was flaky in CI: with a
    random UUID subject, the issued token's signature segment can end on
    a base64url character whose flipped value, when decoded, lenient-
    parses to bits that the JWT decoder accepts as valid — leaving the
    signature still verifiable. The flake was reproducible locally with
    `./gradlew test --rerun-tasks` and was the cause of CI failures on
    the P5.5 and P4.3 docs-pin commits (and would have caught the
    underlying chunks too if the docs commit hadn't pushed first).
    
    Fix: tamper with the PAYLOAD (middle JWT segment) instead. Any
    change to the payload changes the bytes the signature is computed
    over, which ALWAYS fails HMAC verification — no edge cases. The
    test now flips the first character of the payload to a definitely-
    different valid base64url character, leaving the format intact so
    the failure mode is "signature does not verify" rather than
    "malformed token". Verified 10 consecutive `--rerun-tasks` runs
    all green locally.
    zichun authored
     
    Browse Code »
  • The framework's authorization layer is now live. Until now, every
    authenticated user could do everything; the framework had only an
    authentication gate. This chunk adds method-level @RequirePermission
    annotations enforced by a Spring AOP aspect that consults the JWT's
    roles claim and a metadata-driven role-permission map.
    
    What landed
    -----------
    * New `Role` and `UserRole` JPA entities mapping the existing
      identity__role + identity__user_role tables (the schema was
      created in the original identity init but never wired to JPA).
      RoleJpaRepository + UserRoleJpaRepository with a JPQL query that
      returns a user's role codes in one round-trip.
    * `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a
      Set<String> of role codes and encodes them as a `roles` JWT claim
      (sorted for deterministic tests). Refresh tokens NEVER carry roles
      by design — see the rationale on `JwtIssuer.issueRefreshToken`. A
      role revocation propagates within one access-token lifetime
      (15 min default).
    * `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`.
      Missing claim → empty set, NOT an error (refresh tokens, system
      tokens, and pre-P4.3 tokens all legitimately omit it).
    * `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)`
      before minting the access token. `AuthService.refresh` re-reads
      the user's roles too — so a refresh always picks up the latest
      set, since refresh tokens deliberately don't carry roles.
    * New `AuthorizationContext` ThreadLocal in `platform-security.authz`
      carrying an `AuthorizedPrincipal(id, username, roles)`. Separate
      from `PrincipalContext` (which lives in platform-persistence and
      carries only the principal id, for the audit listener). The two
      contexts coexist because the audit listener has no business
      knowing what roles a user has.
    * `PrincipalContextFilter` now populates BOTH contexts on every
      authenticated request, reading the JWT's `username` and `roles`
      claims via `Jwt.getClaimAsStringList("roles")`. The filter is the
      one and only place that knows about Spring Security types AND
      about both vibe_erp contexts; everything downstream uses just the
      Spring-free abstractions.
    * `PermissionEvaluator` Spring bean: takes a role set + permission
      key, returns boolean. Resolution chain:
      1. The literal `admin` role short-circuits to `true` for every
         key (the wildcard exists so the bootstrap admin can do
         everything from the very first boot without seeding a complete
         role-permission mapping).
      2. Otherwise consults an in-memory `Map<role, Set<permission>>`
         loaded from `metadata__role_permission` rows. The cache is
         rebuilt by `refresh()`, called from `VibeErpPluginManager`
         after the initial core load AND after every plug-in load.
      3. Empty role set is always denied. No implicit grants.
    * `@RequirePermission("...")` annotation in `platform-security.authz`.
      `RequirePermissionAspect` is a Spring AOP @Aspect with @Around
      advice that intercepts every annotated method, reads the current
      request's `AuthorizationContext`, calls
      `PermissionEvaluator.has(...)`, and either proceeds or throws
      `PermissionDeniedException`.
    * New `PermissionDeniedException` carrying the offending key.
      `GlobalExceptionHandler` maps it to HTTP 403 Forbidden with
      `"permission denied: 'partners.partner.deactivate'"` as the
      detail. The key IS surfaced to the caller (unlike the 401's
      generic "invalid credentials") because the SPA needs it to
      render a useful "your role doesn't include X" message and
      callers are already authenticated, so it's not an enumeration
      vector.
    * `BootstrapAdminInitializer` now creates the wildcard `admin`
      role on first boot and grants it to the bootstrap admin user.
    * `@RequirePermission` applied to four sensitive endpoints as the
      demo: `PartnerController.deactivate`,
      `StockBalanceController.adjust`, `SalesOrderController.confirm`,
      `SalesOrderController.cancel`. More endpoints will gain
      annotations as additional roles are introduced; v1 keeps the
      blast radius narrow.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Admin login → JWT length 265 (was 241), decoded claims include
      `"roles":["admin"]`
    * Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED
      (admin wildcard short-circuits the permission check)
    * Inserted a 'powerless' user via raw SQL with no role assignments
      but copied the admin's password hash so login works
    * Powerless login → JWT length 247, decoded claims have NO roles
      field at all
    * Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with
      `"permission denied: 'orders.sales.cancel'"` in the body
    * Powerless DELETE /partners/{id} → **403 Forbidden** with
      `"permission denied: 'partners.partner.deactivate'"`
    * Powerless GET /sales-orders, /partners, /catalog/items → all 200
      (read endpoints have no @RequirePermission)
    * Admin regression: catalog uoms, identity users, inventory
      locations, printing-shop plates with i18n, metadata custom-fields
      endpoint — all still HTTP 2xx
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 163 unit tests (was 153),
      all green. The 10 new tests cover:
      - PermissionEvaluator: empty roles deny, admin wildcard, explicit
        role-permission grant, multi-role union, unknown role denial,
        malformed payload tolerance, currentHas with no AuthorizationContext,
        currentHas with bound context (8 tests).
      - JwtRoundTrip: roles claim round-trips through the access token,
        refresh token never carries roles even when asked (2 tests).
    
    What was deferred
    -----------------
    * **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak-
      compatible OIDC client will reuse the same authorization layer
      unchanged — the roles will come from OIDC ID tokens instead of
      the local user store.
    * **Permission key validation at boot.** The framework does NOT
      yet check that every `@RequirePermission` value matches a
      declared metadata permission key. The plug-in linter is the
      natural place for that check to land later.
    * **Role hierarchy**. Roles are flat in v1; a role with permission
      X cannot inherit from another role. Adding a `parent_role` field
      on the role row is a non-breaking change later.
    * **Resource-aware permissions** ("the user owns THIS partner").
      v1 only checks the operation, not the operand. Resource-aware
      checks are post-v1.
    * **Composite (AND/OR) permission requirements**. A single key
      per call site keeps the contract simple. Composite requirements
      live in service code that calls `PermissionEvaluator.currentHas`
      directly.
    * **Role management UI / REST**. The framework can EVALUATE
      permissions but has no first-class endpoints for "create a
      role", "grant a permission to a role", "assign a role to a
      user". v1 expects these to be done via direct DB writes or via
      the future SPA's role editor (P3.x); the wiring above is
      intentionally policy-only, not management.
    zichun authored
     
    Browse Code »
  • The keystone of the framework's "key user adds fields without code"
    promise. A YAML declaration is now enough to add a typed custom field
    to an existing entity, validate it on every save, and surface it to
    the SPA / OpenAPI / AI agent. This is the bit that makes vibe_erp a
    *framework* instead of a fork-per-customer app.
    
    What landed
    -----------
    * Extended `MetadataYamlFile` with a top-level `customFields:` section
      and `CustomFieldYaml` + `CustomFieldTypeYaml` wire-format DTOs. The
      YAML uses a flat `kind: decimal / scale: 2` discriminator instead of
      Jackson polymorphic deserialization so plug-in authors don't have to
      nest type configs and api.v1's `FieldType` stays free of Jackson
      imports.
    * `MetadataLoader.doLoad` now upserts `metadata__custom_field` rows
      alongside entities/permissions/menus, with the same delete-by-source
      idempotency. `LoadResult` carries the count for the boot log.
    * `CustomFieldRegistry` reads every `metadata__custom_field` row from
      the database and builds an in-memory `Map<entityName, List<CustomField>>`
      for the validator's hot path. `refresh()` is called by
      `VibeErpPluginManager` after the initial core load AND after every
      plug-in load, so a freshly-installed plug-in's custom fields are
      immediately enforceable. ConcurrentHashMap under the hood; reads
      are lock-free.
    * `ExtJsonValidator` is the on-save half: takes (entityName, ext map),
      walks the declared fields, coerces each value to its native type,
      and returns the canonicalised map (or throws IllegalArgumentException
      with ALL violations joined for a single 400). Per-FieldType rules:
      - String: maxLength enforced.
      - Integer: accepts Number and numeric String, rejects garbage.
      - Decimal: precision/scale enforced; preserved as plain string in
        canonical form so JSON encoding doesn't lose trailing zeros.
      - Boolean: accepts true/false (case-insensitive).
      - Date / DateTime: ISO-8601 parse via java.time.
      - Uuid: java.util.UUID parse.
      - Enum: must be in declared `allowedValues`.
      - Money / Quantity / Json / Reference: pass-through (Reference target
        existence check pending the cross-PBC EntityRegistry seam).
      Unknown ext keys are rejected with the entity's name and the keys
      themselves listed. ALL violations are returned in one response, not
      failing on the first, so a form submitter fixes everything in one
      round-trip.
    * `Partner` is the first PBC entity to wire ext through the validator:
      `CreatePartnerRequest` and `UpdatePartnerRequest` accept an
      `ext: Map<String, Any?>?`; `PartnerService.create/update` calls
      `extValidator.validate("Partner", ext)` and persists the canonical
      JSON to the existing `partners__partner.ext` JSONB column;
      `PartnerResponse` parses it back so callers see what they wrote.
    * `partners.yml` now declares two custom fields on Partner —
      `partners_credit_limit` (Decimal precision=14, scale=2) and
      `partners_industry` (Enum of printing/publishing/packaging/other) —
      with English and Chinese labels. Tagged `source=core` so the
      framework has something to demo from a fresh boot.
    * New public `GET /api/v1/_meta/metadata/custom-fields/{entityName}`
      endpoint serves the api.v1 runtime view of declarations from the
      in-memory registry (so it reflects every refresh) for the SPA's form
      builder, the OpenAPI generator, and the AI agent function catalog.
      The existing `GET /api/v1/_meta/metadata` endpoint also gained a
      `customFields` list.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Boot log: `CustomFieldRegistry: refreshed 2 custom fields across 1
      entities (0 malformed rows skipped)` — twice (after core load and
      after plug-in load).
    * `GET /api/v1/_meta/metadata/custom-fields/Partner` → both declarations
      with their labels.
    * `POST /api/v1/partners/partners` with `ext = {credit_limit: "50000.00",
      industry: "printing"}` → 201; the response echoes the canonical map.
    * `POST` with `ext = {credit_limit: "1.234", industry: "unicycles",
      rogue: "x"}` → 400 with all THREE violations in one body:
      "ext contains undeclared key(s) for 'Partner': [rogue]; ext.partners_credit_limit:
      decimal scale 3 exceeds declared scale 2; ext.partners_industry:
      value 'unicycles' is not in allowed set [printing, publishing,
      packaging, other]".
    * `SELECT ext FROM partners__partner WHERE code = 'CUST-EXT-OK'` →
      `{"partners_industry": "printing", "partners_credit_limit": "50000.00"}`
      — the canonical JSON is in the JSONB column verbatim.
    * Regression: catalog uoms, identity users, partners list, and the
      printing-shop plug-in's POST /plates (with i18n via Accept-Language:
      zh-CN) all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 13 subprojects, 129 unit tests (was 118), all
      green. The 11 new tests cover each FieldType variant, multi-violation
      reporting, required missing rejection, unknown key rejection, and the
      null-value-dropped case.
    
    What was deferred
    -----------------
    * JPA listener auto-validation. Right now PartnerService explicitly
      calls extValidator.validate(...) before save; Item, Uom, User and
      the plug-in tables don't yet. Promoting the validator to a JPA
      PrePersist/PreUpdate listener attached to a marker interface
      HasExt is the right next step but is its own focused chunk.
    * Reference target existence check. FieldType.Reference passes
      through unchanged because the cross-PBC EntityRegistry that would
      let the validator look up "does an instance with this id exist?"
      is a separate seam landing later.
    * The customization UI (Tier 1 self-service add a field via the SPA)
      is P3.3 — the runtime enforcement is done, the editor is not.
    * Custom-field-aware permissions. The `pii: true` flag is in the
      metadata but not yet read by anything; the DSAR/erasure pipeline
      that will consume it is post-v1.0.
    zichun authored
     
    Browse Code »
  • The eighth cross-cutting platform service is live: plug-ins and PBCs
    now have a real Translator and LocaleProvider instead of the
    UnsupportedOperationException stubs that have shipped since v0.5.
    
    What landed
    -----------
    * New Gradle subproject `platform/platform-i18n` (13 modules total).
    * `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders,
      plurals, gender, locale-aware number/date/currency formatting), the
      format every modern translation tool speaks natively. JDK ResourceBundle
      handles per-locale fallback (zh_CN → zh → root).
    * The translator takes a list of `BundleLocation(classLoader, baseName)`
      pairs and tries them in order. For the **core** translator the chain
      is just `[(host, "messages")]`; for a **per-plug-in** translator
      constructed by VibeErpPluginManager it's
      `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`
      so plug-in keys override host keys, but plug-ins still inherit
      shared keys like `errors.not_found`.
    * Critical detail: the plug-in baseName uses a path the host does NOT
      publish, because PF4J's `PluginClassLoader` is parent-first — a
      `getResource("messages.properties")` against the plug-in classloader
      would find the HOST bundle through the parent chain, defeating the
      per-plug-in override entirely. Naming the plug-in resource somewhere
      the host doesn't claim sidesteps the trap.
    * The translator disables `ResourceBundle.Control`'s automatic JVM-default
      locale fallback. The default control walks `requested → root → JVM
      default → root` which would silently serve German strings to a
      Japanese-locale request just because the German bundle exists. The
      fallback chain stops at root within a bundle, then moves to the next
      bundle location, then returns the key string itself.
    * `RequestLocaleProvider` reads the active HTTP request's
      Accept-Language via `RequestContextHolder` + the servlet container's
      `getLocale()`. Outside an HTTP request (background jobs, workflow
      tasks, MCP agents) it falls back to the configured default locale
      (`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an
      HTTP request HAS no Accept-Language header it ALSO falls back to the
      configured default — never to the JVM's locale.
    * `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider`
      beans. Per-plug-in translators are NOT beans — they're constructed
      imperatively per plug-in start in VibeErpPluginManager because each
      needs its own classloader at the front of the resolution chain.
    * `DefaultPluginContext` now wires `translator` and `localeProvider`
      for real instead of throwing `UnsupportedOperationException`.
    
    Bundles
    -------
    * Core: `platform-i18n/src/main/resources/messages.properties` (English),
      `messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties`
      (German). Six common keys (errors, ok/cancel/save/delete) and an ICU
      plural example for `counts.items`. Java 9+ JEP 226 reads .properties
      files as UTF-8 by default, so Chinese characters are written directly
      rather than as `\\uXXXX` escapes.
    * Reference plug-in: moved from the broken `i18n/messages_en-US.properties`
      / `messages_zh-CN.properties` (wrong path, hyphen-locale filenames
      ResourceBundle ignores) to the canonical
      `META-INF/vibe-erp/i18n/messages.properties` /
      `messages_zh_CN.properties` paths with underscore locale tags.
      Added a new `printingshop.plate.created` key with an ICU plural for
      `ink_count` to demonstrate non-trivial argument substitution.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates
    with three different Accept-Language headers:
    * (no header)         → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle)
    * `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle)
    * `Accept-Language: de`    → "Plate 'PLATE-003' created with no inks." (de, but the plug-in
                                  ships no German bundle so it falls back to the plug-in base
                                  bundle — correct, the key is plug-in-specific)
    Regression: identity, catalog, partners, and `GET /plates` all still
    HTTP 200 after the i18n wiring change.
    
    Build
    -----
    * `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12),
      all green. The 11 new tests cover ICU plural rendering, named-arg
      substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK),
      cross-classloader override (a real JAR built in /tmp at test time),
      and RequestLocaleProvider's three resolution paths
      (no request → default; Accept-Language present → request locale;
      request without Accept-Language → default, NOT JVM locale).
    * The architectural rule still enforced: platform-plugins now imports
      platform-i18n, which is a platform-* dependency (allowed), not a
      pbc-* dependency (forbidden).
    
    What was deferred
    -----------------
    * User-preferred locale from the authenticated user's profile row is
      NOT in the resolution chain yet — the `LocaleProvider` interface
      leaves room for it but the implementation only consults
      Accept-Language and the configured default. Adding it slots in
      between request and default without changing the api.v1 surface.
    * The metadata translation overrides table (`metadata__translation`)
      is also deferred — the `Translator` JavaDoc mentions it as the
      first lookup source, but right now keys come from .properties files
      only. Once Tier 1 customisation lands (P3.x), key users will be able
      to override any string from the SPA without touching code.
    zichun authored
     
    Browse Code »
  • Adds the foundation for the entire Tier 1 customization story. Core
    PBCs and plug-ins now ship YAML files declaring their entities,
    permissions, and menus; a `MetadataLoader` walks the host classpath
    and each plug-in JAR at boot, upserts the rows tagged with their
    source, and exposes them at a public REST endpoint so the future
    SPA, AI-agent function catalog, OpenAPI generator, and external
    introspection tooling can all see what the framework offers without
    scraping code.
    
    What landed:
    
    * New `platform/platform-metadata/` Gradle subproject. Depends on
      api-v1 + platform-persistence + jackson-yaml + spring-jdbc.
    
    * `MetadataYamlFile` DTOs (entities, permissions, menus). Forward-
      compatible: unknown top-level keys are ignored, so a future plug-in
      built against a newer schema (forms, workflows, rules, translations)
      loads cleanly on an older host that doesn't know those sections yet.
    
    * `MetadataLoader` with two entry points:
    
        loadCore() — uses Spring's PathMatchingResourcePatternResolver
          against the host classloader. Finds every classpath*:META-INF/
          vibe-erp/metadata/*.yml across all jars contributing to the
          application. Tagged source='core'.
    
        loadFromPluginJar(pluginId, jarPath) — opens ONE specific
          plug-in JAR via java.util.jar.JarFile and walks its entries
          directly. This is critical: a plug-in's PluginClassLoader is
          parent-first, so a classpath*: scan against it would ALSO
          pick up the host's metadata files via parent classpath. We
          saw this in the first smoke run — the plug-in source ended
          up with 6 entities (the plug-in's 2 + the host's 4) before
          the fix. Walking the JAR file directly guarantees only the
          plug-in's own files load. Tagged source='plugin:<id>'.
    
      Both entry points use the same delete-then-insert idempotent core
      (doLoad). Loading the same source twice produces the same final
      state. User-edited metadata (source='user') is NEVER touched by
      either path — it survives boot, plug-in install, and plug-in
      upgrade. This is what lets a future SPA "Customize" UI add custom
      fields without fearing they'll be wiped on the next deploy.
    
    * `VibeErpPluginManager.afterPropertiesSet()` now calls
      metadataLoader.loadCore() at the very start, then walks plug-ins
      and calls loadFromPluginJar(...) for each one between Liquibase
      migration and start(context). Order is guaranteed: core → linter
      → migrate → metadata → start. The CommandLineRunner I originally
      put `loadCore()` in turned out to be wrong because Spring runs
      CommandLineRunners AFTER InitializingBean.afterPropertiesSet(),
      so the plug-in metadata was loading BEFORE core — the wrong way
      around. Calling loadCore() inline in the plug-in manager fixes
      the ordering without any @Order(...) gymnastics.
    
    * `MetadataController` exposes:
        GET /api/v1/_meta/metadata           — all three sections
        GET /api/v1/_meta/metadata/entities  — entities only
        GET /api/v1/_meta/metadata/permissions
        GET /api/v1/_meta/metadata/menus
      Public allowlist (covered by the existing /api/v1/_meta/** rule
      in SecurityConfiguration). The metadata is intentionally non-
      sensitive — entity names, permission keys, menu paths. Nothing
      in here is PII or secret; the SPA needs to read it before the
      user has logged in.
    
    * YAML files shipped:
      - pbc-identity/META-INF/vibe-erp/metadata/identity.yml
        (User + Role entities, 6 permissions, Users + Roles menus)
      - pbc-catalog/META-INF/vibe-erp/metadata/catalog.yml
        (Item + Uom entities, 7 permissions, Items + UoMs menus)
      - reference plug-in/META-INF/vibe-erp/metadata/printing-shop.yml
        (Plate + InkRecipe entities, 5 permissions, Plates + Inks menus
        in a "Printing shop" section)
    
    Tests: 4 MetadataLoaderTest cases (loadFromPluginJar happy paths,
    mixed sections, blank pluginId rejection, missing-file no-op wipe)
    + 7 MetadataYamlParseTest cases (DTO mapping, optional fields,
    section defaults, forward-compat unknown keys). Total now
    **92 unit tests** across 11 modules, all green.
    
    End-to-end smoke test against fresh Postgres + plug-in loaded:
    
      Boot logs:
        MetadataLoader: source='core' loaded 4 entities, 13 permissions,
          4 menus from 2 file(s)
        MetadataLoader: source='plugin:printing-shop' loaded 2 entities,
          5 permissions, 2 menus from 1 file(s)
    
      HTTP smoke (everything green):
        GET /api/v1/_meta/metadata (no auth)              → 200
          6 entities, 18 permissions, 6 menus
          entity names: User, Role, Item, Uom, Plate, InkRecipe
          menu sections: Catalog, Printing shop, System
        GET /api/v1/_meta/metadata/entities                → 200
        GET /api/v1/_meta/metadata/menus                   → 200
    
      Direct DB verification:
        metadata__entity:    core=4, plugin:printing-shop=2
        metadata__permission: core=13, plugin:printing-shop=5
        metadata__menu:      core=4, plugin:printing-shop=2
    
      Idempotency: restart the app, identical row counts.
    
      Existing endpoints regression:
        GET /api/v1/identity/users (Bearer)               → 1 user
        GET /api/v1/catalog/uoms (Bearer)                  → 15 UoMs
        GET /api/v1/plugins/printing-shop/ping (Bearer)    → 200
    
    Bugs caught and fixed during the smoke test:
    
      • The first attempt loaded core metadata via a CommandLineRunner
        annotated @Order(HIGHEST_PRECEDENCE) and per-plug-in metadata
        inline in VibeErpPluginManager.afterPropertiesSet(). Spring
        runs all InitializingBeans BEFORE any CommandLineRunner, so
        the plug-in metadata loaded first and the core load came
        second — wrong order. Fix: drop CoreMetadataInitializer
        entirely; have the plug-in manager call metadataLoader.loadCore()
        directly at the start of afterPropertiesSet().
    
      • The first attempt's plug-in load used
        metadataLoader.load(pluginClassLoader, ...) which used Spring's
        PathMatchingResourcePatternResolver against the plug-in's
        classloader. PluginClassLoader is parent-first, so the resolver
        enumerated BOTH the plug-in's own JAR AND the host classpath's
        metadata files, tagging core entities as source='plugin:<id>'
        and corrupting the seed counts. Fix: refactor MetadataLoader
        to expose loadFromPluginJar(pluginId, jarPath) which opens
        the plug-in JAR directly via java.util.jar.JarFile and walks
        its entries — never asking the classloader at all. The
        api-v1 surface didn't change.
    
      • Two KDoc comments contained the literal string `*.yml` after
        a `/` character (`/metadata/*.yml`), forming the `/*` pattern
        that Kotlin's lexer treats as a nested-comment opener. The
        file failed to compile with "Unclosed comment". This is the
        third time I've hit this trap; rewriting both KDocs to avoid
        the literal `/*` sequence.
    
      • The MetadataLoaderTest's hand-rolled JAR builder didn't include
        explicit directory entries for parent paths. Real Gradle JARs
        do include them, and Spring's PathMatchingResourcePatternResolver
        needs them to enumerate via classpath*:. Fixed the test helper
        to write directory entries for every parent of each file.
    
    Implementation plan refreshed: P1.5 marked DONE. Next priority
    candidates: P5.2 (pbc-partners — third PBC clone) and P3.4 (custom
    field application via the ext jsonb column, which would unlock the
    full Tier 1 customization story).
    
    Framework state: 17→18 commits, 10→11 modules, 81→92 unit tests,
    metadata seeded for 6 entities + 18 permissions + 6 menus.
    vibe_erp authored
     
    Browse Code »
  • The reference printing-shop plug-in graduates from "hello world" to a
    real customer demonstration: it now ships its own Liquibase changelog,
    owns its own database tables, and exposes a real domain (plates and
    ink recipes) via REST that goes through `context.jdbc` — a new
    typed-SQL surface in api.v1 — without ever touching Spring's
    `JdbcTemplate` or any other host internal type. A bytecode linter
    that runs before plug-in start refuses to load any plug-in that tries
    to import `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` classes.
    
    What landed:
    
    * api.v1 (additive, binary-compatible):
      - PluginJdbc — typed SQL access with named parameters. Methods:
        query, queryForObject, update, inTransaction. No Spring imports
        leaked. Forces plug-ins to use named params (no positional ?).
      - PluginRow — typed nullable accessors over a single result row:
        string, int, long, uuid, bool, instant, bigDecimal. Hides
        java.sql.ResultSet entirely.
      - PluginContext.jdbc getter with default impl that throws
        UnsupportedOperationException so older builds remain binary
        compatible per the api.v1 stability rules.
    
    * platform-plugins — three new sub-packages:
      - jdbc/DefaultPluginJdbc backed by Spring's NamedParameterJdbcTemplate.
        ResultSetPluginRow translates each accessor through ResultSet.wasNull()
        so SQL NULL round-trips as Kotlin null instead of the JDBC defaults
        (0 for int, false for bool, etc. — bug factories).
      - jdbc/PluginJdbcConfiguration provides one shared PluginJdbc bean
        for the whole process. Per-plugin isolation lands later.
      - migration/PluginLiquibaseRunner looks for
        META-INF/vibe-erp/db/changelog.xml inside the plug-in JAR via
        the PF4J classloader and applies it via Liquibase against the
        host's shared DataSource. The unique META-INF path matters:
        plug-ins also see the host's parent classpath, where the host's
        own db/changelog/master.xml lives, and a collision causes
        Liquibase ChangeLogParseException at install time.
      - lint/PluginLinter walks every .class entry in the plug-in JAR
        via java.util.jar.JarFile + ASM ClassReader, visits every type/
        method/field/instruction reference, rejects on any reference to
        `org/vibeerp/platform/` or `org/vibeerp/pbc/` packages.
    
    * VibeErpPluginManager lifecycle is now load → lint → migrate → start:
      - lint runs immediately after PF4J's loadPlugins(); rejected
        plug-ins are unloaded with a per-violation error log and never
        get to run any code
      - migrate runs the plug-in's own Liquibase changelog; failure
        means the plug-in is loaded but skipped (loud warning, framework
        boots fine)
      - then PF4J's startPlugins() runs the no-arg start
      - then we walk loaded plug-ins and call vibe_erp's start(context)
        with a fully-wired DefaultPluginContext (logger + endpoints +
        eventBus + jdbc). The plug-in's tables are guaranteed to exist
        by the time its lambdas run.
    
    * DefaultPluginContext.jdbc is no longer a stub. Plug-ins inject the
      shared PluginJdbc and use it to talk to their own tables.
    
    * Reference plug-in (PrintingShopPlugin):
      - Ships META-INF/vibe-erp/db/changelog.xml with two changesets:
        plugin_printingshop__plate (id, code, name, width_mm, height_mm,
        status) and plugin_printingshop__ink_recipe (id, code, name,
        cmyk_c/m/y/k).
      - Now registers seven endpoints:
          GET  /ping          — health
          GET  /echo/{name}   — path variable demo
          GET  /plates        — list
          GET  /plates/{id}   — fetch
          POST /plates        — create (with race-conditiony existence
                                check before INSERT, since plug-ins
                                can't import Spring's DataAccessException)
          GET  /inks
          POST /inks
      - All CRUD lambdas use context.jdbc with named parameters. The
        plug-in still imports nothing from org.springframework.* in its
        own code (it does reach the host's Jackson via reflection for
        JSON parsing — a deliberate v0.6 shortcut documented inline).
    
    Tests: 5 new PluginLinterTest cases use ASM ClassWriter to synthesize
    in-memory plug-in JARs (clean class, forbidden platform ref, forbidden
    pbc ref, allowed api.v1 ref, multiple violations) and a mocked
    PluginWrapper to avoid touching the real PF4J loader. Total now
    **81 unit tests** across 10 modules, all green.
    
    End-to-end smoke test against fresh Postgres with the plug-in loaded
    (every assertion green):
    
      Boot logs:
        PluginLiquibaseRunner: plug-in 'printing-shop' has changelog.xml
        Liquibase: ChangeSet printingshop-init-001 ran successfully
        Liquibase: ChangeSet printingshop-init-002 ran successfully
        Liquibase migrations applied successfully
        plugin.printing-shop: registered 7 endpoints
    
      HTTP smoke:
        \dt plugin_printingshop*                  → both tables exist
        GET /api/v1/plugins/printing-shop/plates  → []
        POST plate A4                              → 201 + UUID
        POST plate A3                              → 201 + UUID
        POST duplicate A4                          → 409 + clear msg
        GET plates                                 → 2 rows
        GET /plates/{id}                           → A4 details
        psql verifies both rows in plugin_printingshop__plate
        POST ink CYAN                              → 201
        POST ink MAGENTA                           → 201
        GET inks                                   → 2 inks with nested CMYK
        GET /ping                                  → 200 (existing endpoint)
        GET /api/v1/catalog/uoms                   → 15 UoMs (no regression)
        GET /api/v1/identity/users                 → 1 user (no regression)
    
    Bug encountered and fixed during the smoke test:
    
      • The plug-in initially shipped its changelog at db/changelog/master.xml,
        which collides with the HOST's db/changelog/master.xml. The plug-in
        classloader does parent-first lookup (PF4J default), so Liquibase's
        ClassLoaderResourceAccessor found BOTH files and threw
        ChangeLogParseException ("Found 2 files with the path"). Fixed by
        moving the plug-in changelog to META-INF/vibe-erp/db/changelog.xml,
        a path the host never uses, and updating PluginLiquibaseRunner.
        The unique META-INF prefix is now part of the documented plug-in
        convention.
    
    What is explicitly NOT in this chunk (deferred):
    
      • Per-plugin Spring child contexts — plug-ins still instantiate via
        PF4J's classloader without their own Spring beans
      • Per-plugin datasource isolation — one shared host pool today
      • Plug-in changelog table-prefix linter — convention only, runtime
        enforcement comes later
      • Rollback on plug-in uninstall — uninstall is operator-confirmed
        and rare; running dropAll() during stop() would lose data on
        accidental restart
      • Subscription auto-scoping on plug-in stop — plug-ins still close
        their own subscriptions in stop()
      • Real customer-grade JSON parsing in plug-in lambdas — the v0.6
        reference plug-in uses reflection to find the host's Jackson; a
        real plug-in author would ship their own JSON library or use a
        future api.v1 typed-DTO surface
    
    Implementation plan refreshed: P1.2, P1.3, P1.4, P1.7, P4.1, P5.1
    all marked DONE in
    docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md.
    Next priority candidates: P1.5 (metadata seeder) and P5.2 (pbc-partners).
    vibe_erp authored
     
    Browse Code »
  • Adds the framework's event bus, the second cross-cutting service (after
    auth) that PBCs and plug-ins both consume. Implements the transactional
    outbox pattern from the architecture spec section 9 — events are
    written to the database in the same transaction as the publisher's
    domain change, so a publish followed by a rollback never escapes.
    This is the seam where a future Kafka/NATS bridge plugs in WITHOUT
    touching any PBC code.
    
    What landed:
    
    * New `platform/platform-events/` module:
      - `EventOutboxEntry` JPA entity backed by `platform__event_outbox`
        (id, event_id, topic, aggregate_type, aggregate_id, payload jsonb,
        status, attempts, last_error, occurred_at, dispatched_at, version).
        Status enum: PENDING / DISPATCHED / FAILED.
      - `EventOutboxRepository` Spring Data JPA repo with a pessimistic
        SELECT FOR UPDATE query for poller dispatch.
      - `ListenerRegistry` — in-memory subscription holder, indexed both
        by event class (Class.isInstance) and by topic string. Supports
        a `**` wildcard for the platform's audit subscriber. Backed by
        CopyOnWriteArrayList so dispatch is lock-free.
      - `EventBusImpl` — implements the api.v1 EventBus. publish() writes
        the outbox row AND synchronously delivers to in-process listeners
        in the SAME transaction. Marked Propagation.MANDATORY so the bus
        refuses to publish outside an existing transaction (preventing
        publish-and-rollback leaks). Listener exceptions are caught and
        logged; the outbox row still commits.
      - `OutboxPoller` — Spring @Scheduled component that runs every 5s,
        drains PENDING / FAILED rows under a pessimistic lock, marks them
        DISPATCHED. v0.5 has no real external dispatcher — the poller is
        the seam where Kafka/NATS plugs in later.
      - `EventBusConfiguration` — @EnableScheduling so the poller actually
        runs. Lives in this module so the seam activates automatically
        when platform-events is on the classpath.
      - `EventAuditLogSubscriber` — wildcard subscriber that logs every
        event at INFO. Demo proof that the bus works end-to-end. Future
        versions replace it with a real audit log writer.
    
    * `platform__event_outbox` Liquibase changeset (platform-events-001):
      table + unique index on event_id + index on (status, created_at) +
      index on topic.
    
    * DefaultPluginContext.eventBus is no longer a stub that throws —
      it's now the real EventBus injected by VibeErpPluginManager.
      Plug-ins can publish and subscribe via the api.v1 surface. Note:
      subscriptions are NOT auto-scoped to the plug-in lifecycle in v0.5;
      a plug-in that wants its subscriptions removed on stop() must call
      subscription.close() explicitly. Auto-scoping lands when per-plug-in
      Spring child contexts ship.
    
    * pbc-identity now publishes `UserCreatedEvent` after a successful
      UserService.create(). The event class is internal to pbc-identity
      (not in api.v1) — other PBCs subscribe by topic string
      (`identity.user.created`), not by class. This is the right tradeoff:
      string topics are stable across plug-in classloaders, class equality
      is not, and adding every event class to api.v1 would be perpetual
      surface-area bloat.
    
    Tests: 13 new unit tests (9 EventBusImplTest + 4 OutboxPollerTest)
    plus 2 new UserServiceTest cases that verify the publish happens on
    the happy path and does NOT happen when create() rejects a duplicate.
    Total now 76 unit tests across the framework, all green.
    
    End-to-end smoke test against fresh Postgres with the plug-in loaded
    (everything green):
    
      EventAuditLogSubscriber subscribed to ** at boot
      Outbox empty before any user create                      ✓
      POST /api/v1/auth/login                                  → 200
      POST /api/v1/identity/users (create alice)               → 201
      Outbox row appears with topic=identity.user.created,
        status=PENDING immediately after create                ✓
      EventAuditLogSubscriber log line fires synchronously
        inside the create transaction                          ✓
      POST /api/v1/identity/users (create bob)                 → 201
      Wait 8s (one OutboxPoller cycle)
      Both outbox rows now DISPATCHED, dispatched_at set       ✓
      Existing PBCs still work:
        GET /api/v1/identity/users → 3 users                   ✓
        GET /api/v1/catalog/uoms → 15 UoMs                     ✓
      Plug-in still works:
        GET /api/v1/plugins/printing-shop/ping → 200           ✓
    
    The most important assertion is the synchronous audit log line
    appearing on the same thread as the user creation request. That
    proves the entire chain — UserService.create() → eventBus.publish()
    → EventBusImpl writes outbox row → ListenerRegistry.deliver()
    finds wildcard subscriber → EventAuditLogSubscriber.handle()
    logs — runs end-to-end inside the publisher's transaction.
    The poller flipping PENDING → DISPATCHED 5s later proves the
    outbox + poller seam works without any external dispatcher.
    
    Bug encountered and fixed during the smoke test:
    
      • EventBusImplTest used `ObjectMapper().registerKotlinModule()`
        which doesn't pick up jackson-datatype-jsr310. Production code
        uses Spring Boot's auto-configured ObjectMapper which already
        has jsr310 because spring-boot-starter-web is on the classpath
        of distribution. The test setup was the only place using a bare
        mapper. Fixed by switching to `findAndRegisterModules()` AND
        by adding jackson-datatype-jsr310 as an explicit implementation
        dependency of platform-events (so future modules that depend on
        the bus without bringing web in still get Instant serialization).
    
    What is explicitly NOT in this chunk:
    
      • External dispatcher (Kafka/NATS bridge) — the poller is a no-op
        that just marks rows DISPATCHED. The seam exists; the dispatcher
        is a future P1.7.b unit.
      • Exponential backoff on FAILED rows — every cycle re-attempts.
        Real backoff lands when there's a real dispatcher to fail.
      • Dead-letter queue — same.
      • Per-plug-in subscription auto-scoping — plug-ins must close()
        explicitly today.
      • Async / fire-and-forget publish — synchronous in-process only.
    vibe_erp authored
     
    Browse Code »
  • The reference printing-shop plug-in now actually does something:
    its main class registers two HTTP endpoints during start(context),
    and a real curl to /api/v1/plugins/printing-shop/ping returns the
    JSON the plug-in's lambda produced. End-to-end smoke test 10/10
    green. This is the chunk that turns vibe_erp from "an ERP app
    that has a plug-in folder" into "an ERP framework whose plug-ins
    can serve traffic".
    
    What landed:
    
    * api.v1 — additive (binary-compatible per the api.v1 stability rule):
      - org.vibeerp.api.v1.plugin.HttpMethod (enum)
      - org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body)
      - org.vibeerp.api.v1.plugin.PluginResponse (status + body)
      - org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface)
      - org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin
        scoped, register(method, path, handler))
      - PluginContext.endpoints getter with default impl that throws
        UnsupportedOperationException so the addition is binary-compatible
        with plug-ins compiled against earlier api.v1 builds.
    
    * platform-plugins — three new files:
      - PluginEndpointRegistry: process-wide registration storage. Uses
        Spring's AntPathMatcher so {var} extracts path variables.
        Synchronized mutation. Exact-match fast path before pattern loop.
        Rejects duplicate (method, path) per plug-in. unregisterAll(plugin)
        on shutdown.
      - ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every
        register() call with the right plugin id. Plug-ins cannot register
        under another plug-in's namespace.
      - PluginEndpointDispatcher: single Spring @RestController at
        /api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE,
        asks the registry for a match, builds a PluginRequest, calls the
        handler, serializes the response. 404 on no match, 500 on handler
        throw (logged with stack trace).
      - DefaultPluginContext: implements PluginContext with a real
        SLF4J-backed logger (every line tagged with the plug-in id) and
        the scoped endpoint registrar. The other six services
        (eventBus, transaction, translator, localeProvider,
        permissionCheck, entityRegistry) throw UnsupportedOperationException
        with messages pointing at the implementation plan unit that will
        land each one. Loud failure beats silent no-op.
    
    * VibeErpPluginManager — after PF4J's startPlugins() now walks every
      loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin,
      and calls start(context) with a freshly-built DefaultPluginContext.
      Tracks the started set so destroy() can call stop() and
      unregisterAll() in reverse order. Catches plug-in start failures
      loudly without bringing the framework down.
    
    * Reference plug-in (PrintingShopPlugin):
      - Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate
        it via the Plugin-Class manifest entry) AND
        org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle
        hook can call start(context)). Uses Kotlin import aliases to
        disambiguate the two `Plugin` simple names.
      - In start(context), registers two endpoints:
          GET /ping — returns {plugin, version, ok, message}
          GET /echo/{name} — extracts path variable, echoes it back
      - The /echo handler proves path-variable extraction works end-to-end.
    
    * Build infrastructure:
      - reference-customer/plugin-printing-shop now has an `installToDev`
        Gradle task that builds the JAR and stages it into <repo>/plugins-dev/.
        The task wipes any previous staged copies first so renaming the JAR
        on a version bump doesn't leave PF4J trying to load two versions.
      - distribution's `bootRun` task now (a) depends on `installToDev`
        so the staging happens automatically and (b) sets workingDir to
        the repo root so application-dev.yaml's relative
        `vibeerp.plugins.directory: ./plugins-dev` resolves to the right
        place. Without (b) bootRun's CWD was distribution/ and PF4J found
        "No plugins" — which is exactly the bug that surfaced in the first
        smoke run.
      - .gitignore now excludes /plugins-dev/ and /files-dev/.
    
    Tests: 12 new unit tests for PluginEndpointRegistry covering literal
    paths, single/multi path variables, duplicate registration rejection,
    literal-vs-pattern precedence, cross-plug-in isolation, method
    matching, and unregisterAll. Total now 61 unit tests across the
    framework, all green.
    
    End-to-end smoke test against fresh Postgres + the plug-in JAR
    loaded by PF4J at boot (10/10 passing):
      GET /api/v1/plugins/printing-shop/ping (no auth)         → 401
      POST /api/v1/auth/login                                  → access token
      GET /api/v1/plugins/printing-shop/ping (Bearer)          → 200
                                                                 {plugin, version, ok, message}
      GET /api/v1/plugins/printing-shop/echo/hello             → 200, echoed=hello
      GET /api/v1/plugins/printing-shop/echo/world             → 200, echoed=world
      GET /api/v1/plugins/printing-shop/nonexistent            → 404 (no handler)
      GET /api/v1/plugins/missing-plugin/ping                  → 404 (no plugin)
      POST /api/v1/plugins/printing-shop/ping                  → 404 (wrong method)
      GET /api/v1/catalog/uoms (Bearer)                        → 200, 15 UoMs
      GET /api/v1/identity/users (Bearer)                      → 200, 1 user
    
    PF4J resolved the JAR, started the plug-in, the host called
    vibe_erp's start(context), the plug-in registered two endpoints, and
    the dispatcher routed real HTTP traffic to the plug-in's lambdas.
    The boot log shows the full chain.
    
    What is explicitly NOT in this chunk and remains for later:
      • plug-in linter (P1.2) — bytecode scan for forbidden imports
      • plug-in Liquibase application (P1.4) — plug-in-owned schemas
      • per-plug-in Spring child context — currently we just instantiate
        the plug-in via PF4J's classloader; there is no Spring context
        for the plug-in's own beans
      • PluginContext.eventBus / transaction / translator / etc. — they
        still throw UnsupportedOperationException with TODO messages
      • Path-template precedence between multiple competing patterns
        (only literal-beats-pattern is implemented, not most-specific-pattern)
      • Permission checks at the dispatcher (Spring Security still
        catches plug-in endpoints with the global "anyRequest authenticated"
        rule, which is the right v0.5 behavior)
      • Hot reload of plug-ins (cold restart only)
    
    Bug encountered and fixed during the smoke test:
    
      • application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`,
        a relative path. Gradle's `bootRun` task by default uses the
        subproject's directory as the working directory, so the relative
        path resolved to <repo>/distribution/plugins-dev/ instead of
        <repo>/plugins-dev/. PF4J reported "No plugins" because that
        directory was empty. Fixed by setting bootRun.workingDir =
        rootProject.layout.projectDirectory.asFile.
    
      • One KDoc comment in PluginEndpointDispatcher contained the literal
        string `/api/v1/plugins/{pluginId}/**` inside backticks. The
        Kotlin lexer doesn't treat backticks as comment-suppressing, so
        `/**` opened a nested KDoc comment that was never closed and the
        file failed to compile. Same root cause as the AuthController bug
        earlier in the session. Rewrote the line to avoid the literal
        `/**` sequence.
    vibe_erp authored
     
    Browse Code »

  • Implements the auth unit from the implementation plan. Until now, the
    framework let any caller hit any endpoint; with the single-tenant
    refactor there is no second wall, so auth was the most pressing gap.
    
    What landed:
    
    * New `platform-security` module owns the framework's security
      primitives (JWT issuer/verifier, password encoder, Spring Security
      filter chain config, AuthenticationFailedException). Lives between
      platform-persistence and platform-bootstrap.
    
    * `JwtIssuer` mints HS256-signed access (15min) and refresh (7d) tokens
      via NimbusJwtEncoder. `JwtVerifier` decodes them back to a typed
      `DecodedToken` so PBCs never need to import OAuth2 types. JWT secret
      is read from VIBEERP_JWT_SECRET; the framework refuses to start if
      the secret is shorter than 32 bytes.
    
    * `SecurityConfiguration` wires Spring Security with JWT resource
      server, stateless sessions, CSRF disabled, and a public allowlist
      for /actuator/health, /actuator/info, /api/v1/_meta/**,
      /api/v1/auth/login, /api/v1/auth/refresh.
    
    * `PrincipalContext` (in platform-persistence/security) is the bridge
      between Spring Security's SecurityContextHolder and the audit
      listener. Bound by `PrincipalContextFilter` which runs AFTER
      BearerTokenAuthenticationFilter so SecurityContextHolder is fully
      populated. The audit listener (AuditedJpaEntityListener) now reads
      from PrincipalContext, so created_by/updated_by are real user ids
      instead of __system__.
    
    * `pbc-identity` gains `UserCredential` (separate table from User —
      password hashes never share a query plan with user records),
      `AuthService` (login + refresh, generic AuthenticationFailedException
      on every failure to thwart account enumeration), and `AuthController`
      exposing /api/v1/auth/login and /api/v1/auth/refresh.
    
    * `BootstrapAdminInitializer` runs on first boot of an empty
      identity__user table, creates an `admin` user with a random
      16-char password printed to the application logs. Subsequent
      boots see the user exists and skip silently.
    
    * GlobalExceptionHandler maps AuthenticationFailedException → 401
      with a generic "invalid credentials" body (RFC 7807 ProblemDetail).
    
    * New module also brings BouncyCastle as a runtime-only dep
      (Argon2PasswordEncoder needs it).
    
    Tests: 38 unit tests pass, including JwtRoundTripTest (issue/decode
    round trip + tamper detection + secret-length validation),
    PrincipalContextTest (ThreadLocal lifecycle), AuthServiceTest (9 cases
    covering login + refresh happy paths and every failure mode).
    
    End-to-end smoke test against a fresh Postgres via docker-compose:
      GET /api/v1/identity/users (no auth)        → 401
      POST /api/v1/auth/login (admin + bootstrap) → 200 + access/refresh
      POST /api/v1/auth/login (wrong password)    → 401
      GET  /api/v1/identity/users (Bearer)        → 200, lists admin
      POST /api/v1/identity/users (Bearer)        → 201, creates alice
      alice.created_by                            → admin's user UUID
      POST /api/v1/auth/refresh (refresh token)   → 200 + new pair
      POST /api/v1/auth/refresh (access token)    → 401 (type mismatch)
      GET  /api/v1/identity/users (garbage token) → 401
      GET  /api/v1/_meta/info (no auth, public)   → 200
    
    Plan: docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
    refreshed to drop the now-dead P1.1 (RLS hook) and H1 (per-region
    tenant routing), reorder priorities so P4.1 is first, and reflect the
    single-tenant change throughout.
    
    Bug fixes encountered along the way (caught by the smoke test, not by
    unit tests — the value of running real workflows):
    
      • JwtIssuer was producing IssuedToken.expiresAt with nanosecond
        precision but JWT exp is integer seconds; the round-trip test
        failed equality. Fixed by truncating to ChronoUnit.SECONDS at
        issue time.
      • PrincipalContextFilter was registered with addFilterAfter
        UsernamePasswordAuthenticationFilter, which runs BEFORE the
        OAuth2 BearerTokenAuthenticationFilter, so SecurityContextHolder
        was empty when the bridge filter read it. Result: every
        authenticated request still wrote __system__ in audit columns.
        Fixed by addFilterAfter BearerTokenAuthenticationFilter::class.
      • RefreshRequest is a single-String data class. jackson-module-kotlin
        interprets single-arg data classes as delegate-based creators, so
        Jackson tried to deserialize the entire JSON object as a String
        and threw HttpMessageNotReadableException. Fixed by adding
        @JsonCreator(mode = PROPERTIES) + @param:JsonProperty.
    vibe_erp authored
     
    Browse Code »
  • Design change: vibe_erp deliberately does NOT support multiple companies in
    one process. Each running instance serves exactly one company against an
    isolated Postgres database. Hosting many customers means provisioning many
    independent instances, not multiplexing them.
    
    Why: most ERP/EBC customers will not accept a SaaS where their data shares
    a database with other companies. The single-tenant-per-instance model is
    what the user actually wants the product to look like, and it dramatically
    simplifies the framework.
    
    What changed:
    - CLAUDE.md guardrail #5 rewritten from "multi-tenant from day one" to
      "single-tenant per instance, isolated database"
    - api.v1: removed TenantId value class entirely; removed tenantId from
      Entity, AuditedEntity, Principal, DomainEvent, RequestContext,
      TaskContext, IdentityApi.UserRef, Repository
    - platform-persistence: deleted TenantContext, HibernateTenantResolver,
      TenantAwareJpaTransactionManager, TenancyJpaConfiguration; removed
      @TenantId and tenant_id column from AuditedJpaEntity
    - platform-bootstrap: deleted TenantResolutionFilter; dropped
      vibeerp.instance.mode and default-tenant from properties; added
      vibeerp.instance.company-name; added VibeErpApplication @EnableJpaRepositories
      and @EntityScan so PBC repositories outside the main package are wired;
      added GlobalExceptionHandler that maps IllegalArgumentException → 400
      and NoSuchElementException → 404 (RFC 7807 ProblemDetail)
    - pbc-identity: removed tenant_id from User, repository, controller, DTOs,
      IdentityApiAdapter; updated UserService duplicate-username message and
      the matching test
    - distribution: dropped multiTenancy=DISCRIMINATOR and
      tenant_identifier_resolver from application.yaml; configured Spring Boot
      mainClass on the springBoot extension (not just bootJar) so bootRun works
    - Liquibase: rewrote platform-init changelog to drop platform__tenant and
      the tenant_id columns on every metadata__* table; rewrote
      pbc-identity init to drop tenant_id columns, the (tenant_id, *)
      composite indexes, and the per-table RLS policies
    - IdentifiersTest replaced with Id<T> tests since the TenantId tests
      no longer apply
    
    Verified end-to-end against a real Postgres via docker-compose:
      POST /api/v1/identity/users   → 201 Created
      GET  /api/v1/identity/users   → list works
      GET  /api/v1/identity/users/X → fetch by id works
      POST duplicate username       → 400 Bad Request (was 500)
      PATCH bogus id                → 404 Not Found (was 500)
      PATCH alice                   → 200 OK
      DELETE alice                  → 204, alice now disabled
    
    All 18 unit tests pass.
    vibe_erp authored
     
    Browse Code »
  • BLOCKER: wire Hibernate multi-tenancy
    - application.yaml: set hibernate.tenant_identifier_resolver and
      hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is
      actually installed into the SessionFactory
    - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so
      every PBC entity inherits the discriminator
    - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId
      to a different value than the current TenantContext, instead of
      silently overwriting (defense against cross-tenant write bugs)
    
    IMPORTANT: dependency hygiene
    - pbc-identity no longer depends on platform-bootstrap (wrong direction;
      bootstrap assembles PBCs at the top of the stack)
    - root build.gradle.kts: tighten the architectural-rule enforcement to
      also reject :pbc:* -> platform-bootstrap; switch plug-in detection
      from a fragile pathname heuristic to an explicit
      extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in
      declares the marker
    
    IMPORTANT: api.v1 surface additions (all non-breaking)
    - Repository: documented closed exception set; new
      PersistenceExceptions.kt declares OptimisticLockConflictException,
      UniqueConstraintViolationException, EntityValidationException, and
      EntityNotFoundException so plug-ins never see Hibernate types
    - TaskContext: now exposes tenantId(), principal(), locale(),
      correlationId() so workflow handlers (which run outside an HTTP
      request) can pass tenant-aware calls back into api.v1
    - EventBus: subscribe() now returns a Subscription with close() so
      long-lived subscribers can deregister explicitly; added a
      subscribe(topic: String, ...) overload for cross-classloader event
      routing where Class<E> equality is unreliable
    - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the
      type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary
    
    NITs:
    - HealthController.kt -> MetaController.kt (file name now matches the
      class name); added TODO(v0.2) for reading implementationVersion from
      the Spring Boot BuildProperties bean
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »