• First step of pbc-finance GL growth: the chart of accounts.
    
    Backend:
      - Account entity (code, name, accountType: ASSET/LIABILITY/EQUITY/
        REVENUE/EXPENSE, description, active)
      - AccountJpaRepository + AccountService (list, findById, findByCode,
        create with duplicate-code guard)
      - AccountController at /api/v1/finance/accounts (GET list, GET by id,
        POST create). Permission-gated: finance.account.read, .create.
      - Liquibase 003-finance-accounts.xml: table + unique code index +
        6 seeded accounts (1000 Cash, 1100 AR, 1200 Inventory, 2100 AP,
        4100 Sales Revenue, 5100 COGS)
      - finance.yml updated: Account entity + 2 permissions + menu entry
    
    SPA:
      - AccountsPage with sortable list + inline create form
      - finance.listAccounts + finance.createAccount in typed API client
      - Sidebar: "Chart of Accounts" above "Journal Entries" in Finance
      - Route /accounts wired in App.tsx + SpaController + SecurityConfig
    
    This is the foundation for the next step (JournalEntryLine child
    entity with per-account debit/credit legs + balanced-entry
    validation). The seeded chart covers the 6 accounts the existing
    event subscribers will reference once the double-entry lines land.
    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 »

  • 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 »

  • 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 »