-
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. -
Adds the second core PBC, validating that the pbc-identity template is actually clonable and that the Gradle dependency rule fires correctly for a real second PBC. What landed: * New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity: api-v1 + platform-persistence + platform-security only (no platform-bootstrap, no other pbc). The architecture rule in the root build.gradle.kts now has two real PBCs to enforce against. * `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb. Code is the natural key (stable, human-readable). UomService rejects duplicate codes and refuses to update the code itself (would invalidate every Item FK referencing it). UomController at /api/v1/catalog/uoms exposes list, get-by-id, get-by-code, create, update. * `Item` entity (catalog__item) — code, name, description, item_type (GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb. ItemService validates the referenced UoM exists at the application layer (better error message than the DB FK alone), refuses to update code or baseUomCode (data-migration operations, not edits), supports soft delete via deactivate. ItemController at /api/v1/catalog/items with full CRUD. * `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade in api.v1 (after IdentityApi). Exposes findItemByCode(code) and findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items are filtered to null at the boundary so callers cannot accidentally reference deactivated catalog rows. * `CatalogApiAdapter` in pbc-catalog — concrete @Component implementing CatalogApi. Maps internal entities to api.v1 DTOs without leaking storage types. * Liquibase changeset (catalog-init-001..003) creates both tables with unique indexes on code, GIN indexes on ext, and seeds 15 canonical units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area), l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged created_by='__seed__' so a future metadata uninstall sweep can identify them. Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6), total now 49 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres via docker-compose (14/14 passing): GET /api/v1/catalog/items (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/catalog/uoms (Bearer) → 15 seeded UoMs GET /api/v1/catalog/uoms/by-code/kg → 200 POST custom UoM 'roll' → 201 POST duplicate UoM 'kg' → 400 + clear message GET items → [] POST item with unknown UoM → 400 + clear message POST item with valid UoM → 201 catalog__item.created_by → admin user UUID (NOT __system__) GET /by-code/INK-CMYK-CYAN → 200 PATCH item name + description → 200 DELETE item → 204 GET item → active=false The principal-context bridge from P4.1 keeps working without any additional wiring in pbc-catalog: every PBC inherits the audit behavior for free by extending AuditedJpaEntity. That is exactly the "PBCs follow a recipe, the framework provides the cross-cutting machinery" promise from the architecture spec. Architectural rule enforcement still active: confirmed by reading the build.gradle.kts and observing that pbc-catalog declares no :platform:platform-bootstrap and no :pbc:pbc-identity dependency. The build refuses to load on either violation.
-
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.