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

  • 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 »
  • 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 »
  • …er, IdentityApi adapter, unit tests)
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »