-
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.
-
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.
-
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. -
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.