diff --git a/.github/workflows/plugin_host_ci.yml b/.github/workflows/plugin_host_ci.yml new file mode 100644 index 0000000000..ea75037804 --- /dev/null +++ b/.github/workflows/plugin_host_ci.yml @@ -0,0 +1,137 @@ +name: Plugin host & SDK - build and test + +permissions: + contents: read + +on: + push: + paths: + - "plugin-host/**" + - ".github/workflows/plugin_host_ci.yml" + pull_request: + paths: + - "plugin-host/**" + - ".github/workflows/plugin_host_ci.yml" + workflow_dispatch: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Node 22 matches the host runtime requirement (Extism runInWorker needs Node >= 22, plan §7). + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + plugin-host/plugin-sdk/package-lock.json + plugin-host/app/package-lock.json + + # The SDK is a file: dependency of the app and must be built first so the app's `tsc` can + # resolve @valtimo/plugin-sdk types. + - name: SDK - install + working-directory: plugin-host/plugin-sdk + run: npm ci + + - name: SDK - build (tsc) + working-directory: plugin-host/plugin-sdk + run: npm run build + + - name: SDK - test + working-directory: plugin-host/plugin-sdk + run: npm run test:cov + + - name: App - install + working-directory: plugin-host/app + run: npm ci + + - name: App - build (tsc) + working-directory: plugin-host/app + run: npm run build + + - name: App - test + working-directory: plugin-host/app + run: npm run test:cov + + # L3 (Wasm/Extism) tests: compile the fixture plugin with the real SDK toolchain and run it under + # Extism. Kept separate from `unit` because it needs the extism-js compiler, which is not committed + # (plugin-host/.bin is gitignored) and is downloaded here. + wasm: + runs-on: ubuntu-latest + env: + # Pin the Extism JS PDK compiler version. Bump together with @extism/js-pdk in the fixture. + EXTISM_JS_VERSION: "1.5.1" + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + plugin-host/plugin-sdk/package-lock.json + plugin-host/app/package-lock.json + plugin-host/test-fixtures/test-plugin/package-lock.json + + - name: Download extism-js compiler + run: | + mkdir -p plugin-host/.bin + url="https://github.com/extism/js-pdk/releases/download/v${EXTISM_JS_VERSION}/extism-js-x86_64-linux-v${EXTISM_JS_VERSION}.gz" + curl -sSfL "$url" -o /tmp/extism-js.gz + gunzip -c /tmp/extism-js.gz > plugin-host/.bin/extism-js + chmod +x plugin-host/.bin/extism-js + plugin-host/.bin/extism-js --version + + # The fixture bundles @valtimo/plugin-sdk from its built dist, so the SDK must be built first. + - name: SDK - install & build + working-directory: plugin-host/plugin-sdk + run: npm ci && npm run build + + - name: Fixture - install deps + working-directory: plugin-host/test-fixtures/test-plugin + run: npm install + + - name: App - install + working-directory: plugin-host/app + run: npm ci + + # globalSetup compiles the fixture to Wasm (finds extism-js on PATH via plugin-host/.bin). + - name: App - Wasm/Extism tests (L3) + working-directory: plugin-host/app + run: npm run test:wasm + + # L4 (integration) tests: real Postgres + RabbitMQ via Testcontainers. The ubuntu runner ships a + # working Docker daemon, which Testcontainers uses directly. + integration: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + plugin-host/plugin-sdk/package-lock.json + plugin-host/app/package-lock.json + + # The app's tsc/type surface pulls @valtimo/plugin-sdk, so build the SDK first. + - name: SDK - install & build + working-directory: plugin-host/plugin-sdk + run: npm ci && npm run build + + - name: App - install + working-directory: plugin-host/app + run: npm ci + + - name: App - integration tests (Testcontainers) + working-directory: plugin-host/app + run: npm run test:int diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/autoconfigure/AdminSettingsAutoConfiguration.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/autoconfigure/AdminSettingsAutoConfiguration.kt index dfdf7e8d70..f7782b0cc7 100644 --- a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/autoconfigure/AdminSettingsAutoConfiguration.kt +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/autoconfigure/AdminSettingsAutoConfiguration.kt @@ -23,13 +23,16 @@ import com.ritense.adminsettings.importer.AdminSettingsLogoImporter import com.ritense.adminsettings.repository.AccentColorsRepository import com.ritense.adminsettings.repository.AdminSettingsLogoRepository import com.ritense.adminsettings.repository.FeatureToggleOverridesRepository +import com.ritense.adminsettings.repository.MenuConfigurationRepository import com.ritense.adminsettings.security.config.AdminSettingsHttpSecurityConfigurer import com.ritense.adminsettings.service.AccentColorsService import com.ritense.adminsettings.service.AdminSettingsLogoService import com.ritense.adminsettings.service.FeatureToggleOverridesService +import com.ritense.adminsettings.service.MenuConfigurationService import com.ritense.adminsettings.web.rest.AccentColorsResource import com.ritense.adminsettings.web.rest.AdminSettingsLogoResource import com.ritense.adminsettings.web.rest.FeatureToggleOverridesResource +import com.ritense.adminsettings.web.rest.MenuConfigurationResource import com.ritense.authorization.AuthorizationService import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean @@ -39,7 +42,7 @@ import org.springframework.core.annotation.Order import org.springframework.data.jpa.repository.config.EnableJpaRepositories @AutoConfiguration -@EnableJpaRepositories(basePackageClasses = [AdminSettingsLogoRepository::class, FeatureToggleOverridesRepository::class, AccentColorsRepository::class]) +@EnableJpaRepositories(basePackageClasses = [AdminSettingsLogoRepository::class, FeatureToggleOverridesRepository::class, AccentColorsRepository::class, MenuConfigurationRepository::class]) @EntityScan(basePackages = ["com.ritense.adminsettings.domain"]) class AdminSettingsAutoConfiguration { @@ -110,6 +113,26 @@ class AdminSettingsAutoConfiguration { return AccentColorsResource(accentColorsService) } + @Bean + @ConditionalOnMissingBean(MenuConfigurationService::class) + fun menuConfigurationService( + menuConfigurationRepository: MenuConfigurationRepository, + objectMapper: ObjectMapper, + ): MenuConfigurationService { + return MenuConfigurationService( + menuConfigurationRepository, + objectMapper, + ) + } + + @Bean + @ConditionalOnMissingBean(MenuConfigurationResource::class) + fun menuConfigurationResource( + menuConfigurationService: MenuConfigurationService + ): MenuConfigurationResource { + return MenuConfigurationResource(menuConfigurationService) + } + @Bean @ConditionalOnMissingBean(AdminSettingsFeatureToggleImporter::class) fun adminSettingsFeatureToggleImporter( diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/domain/MenuConfiguration.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/domain/MenuConfiguration.kt new file mode 100644 index 0000000000..d7515afc57 --- /dev/null +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/domain/MenuConfiguration.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * Singleton row holding the application's left-hand menu structure as opaque, frontend-owned JSON + * (exactly like [AccentColors] holds colours). The backend never interprets the structure; the + * frontend resolves it against its catalog registry at startup. + */ +@Entity +@Table(name = "admin_settings_menu_configuration") +open class MenuConfiguration( + + @Id + @Column(name = "id", nullable = false) + open val id: String = SINGLETON_ID, + + @Column(name = "configuration", columnDefinition = "TEXT", nullable = false) + open var configuration: String = "{}" +) { + + companion object { + const val SINGLETON_ID = "singleton" + } +} diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/repository/MenuConfigurationRepository.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/repository/MenuConfigurationRepository.kt new file mode 100644 index 0000000000..a567db865b --- /dev/null +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/repository/MenuConfigurationRepository.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.repository + +import com.ritense.adminsettings.domain.MenuConfiguration +import org.springframework.data.jpa.repository.JpaRepository + +interface MenuConfigurationRepository : JpaRepository diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/security/config/AdminSettingsHttpSecurityConfigurer.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/security/config/AdminSettingsHttpSecurityConfigurer.kt index a747938aa5..7b23a1f3dc 100644 --- a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/security/config/AdminSettingsHttpSecurityConfigurer.kt +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/security/config/AdminSettingsHttpSecurityConfigurer.kt @@ -50,6 +50,10 @@ class AdminSettingsHttpSecurityConfigurer : HttpSecurityConfigurer { .authenticated() .requestMatchers(antMatcher(PUT, "/api/management/v1/admin-settings/accent-colors")) .hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/v1/admin-settings/menu-configuration")) + .authenticated() + .requestMatchers(antMatcher(PUT, "/api/management/v1/admin-settings/menu-configuration")) + .hasAuthority(ADMIN) } } catch (e: Exception) { throw HttpConfigurerConfigurationException(e) diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/service/MenuConfigurationService.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/service/MenuConfigurationService.kt new file mode 100644 index 0000000000..645747c10e --- /dev/null +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/service/MenuConfigurationService.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.adminsettings.domain.MenuConfiguration +import com.ritense.adminsettings.domain.MenuConfiguration.Companion.SINGLETON_ID +import com.ritense.adminsettings.repository.MenuConfigurationRepository +import com.ritense.adminsettings.web.rest.dto.MenuConfigurationDto +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException + +open class MenuConfigurationService( + private val menuConfigurationRepository: MenuConfigurationRepository, + private val objectMapper: ObjectMapper, +) { + + @Transactional(readOnly = true) + open fun getMenuConfiguration(): MenuConfigurationDto { + val entity = menuConfigurationRepository.findByIdOrNull(SINGLETON_ID) + val configuration: JsonNode = if (entity != null) { + objectMapper.readTree(entity.configuration) + } else { + objectMapper.createObjectNode() + } + return MenuConfigurationDto(configuration) + } + + @Transactional + open fun updateMenuConfiguration(configuration: JsonNode): MenuConfigurationDto { + val serializedConfiguration = objectMapper.writeValueAsString(configuration) + if (serializedConfiguration.length > MAX_CONFIGURATION_LENGTH) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Menu configuration exceeds the maximum size of $MAX_CONFIGURATION_LENGTH characters" + ) + } + + val entity = menuConfigurationRepository.findByIdOrNull(SINGLETON_ID) + ?: MenuConfiguration() + + entity.configuration = serializedConfiguration + menuConfigurationRepository.save(entity) + + return MenuConfigurationDto(configuration) + } + + companion object { + const val MAX_CONFIGURATION_LENGTH = 100_000 + } +} diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AccentColorsResource.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AccentColorsResource.kt index 55658117f9..8d00c17bed 100644 --- a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AccentColorsResource.kt +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AccentColorsResource.kt @@ -20,6 +20,7 @@ import com.ritense.adminsettings.service.AccentColorsService import com.ritense.adminsettings.web.rest.dto.AccentColorsDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -35,11 +36,19 @@ class AccentColorsResource( private val accentColorsService: AccentColorsService ) { + @EndpointDescription( + en = "Get accent colors", + nl = "Accentkleuren ophalen", + ) @GetMapping("/v1/admin-settings/accent-colors") fun getColors(): ResponseEntity { return ResponseEntity.ok(accentColorsService.getColors()) } + @EndpointDescription( + en = "Update accent colors", + nl = "Accentkleuren bijwerken", + ) @PutMapping("/management/v1/admin-settings/accent-colors") fun updateColors( @Valid @RequestBody dto: AccentColorsDto diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AdminSettingsLogoResource.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AdminSettingsLogoResource.kt index 468c7fe341..effe51478c 100644 --- a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AdminSettingsLogoResource.kt +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/AdminSettingsLogoResource.kt @@ -24,6 +24,7 @@ import com.ritense.adminsettings.web.rest.dto.CreateAdminSettingsLogoDto import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -41,12 +42,20 @@ class AdminSettingsLogoResource( private val adminSettingsLogoService: AdminSettingsLogoService ) { + @EndpointDescription( + en = "List logos", + nl = "Logo's ophalen", + ) @GetMapping("/v1/admin-settings/logos") fun getLogos(): ResponseEntity { val logos = adminSettingsLogoService.getLogos() return ResponseEntity.ok(logos) } + @EndpointDescription( + en = "Get logo by type", + nl = "Logo ophalen op type", + ) @GetMapping("/management/v1/admin-settings/logo/{logoType}") fun getLogo( @PathVariable logoType: LogoType @@ -56,6 +65,10 @@ class AdminSettingsLogoResource( ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Upload logo by type", + nl = "Logo uploaden op type", + ) @PostMapping( path = ["/management/v1/admin-settings/logo/{logoType}"], consumes = [APPLICATION_JSON_UTF8_VALUE] @@ -68,6 +81,10 @@ class AdminSettingsLogoResource( return ResponseEntity.ok(created) } + @EndpointDescription( + en = "Delete logo by type", + nl = "Logo verwijderen op type", + ) @DeleteMapping("/management/v1/admin-settings/logo/{logoType}") fun deleteLogo( @PathVariable logoType: LogoType diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/FeatureToggleOverridesResource.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/FeatureToggleOverridesResource.kt index bf0b8e42cf..c7f4104527 100644 --- a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/FeatureToggleOverridesResource.kt +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/FeatureToggleOverridesResource.kt @@ -21,6 +21,7 @@ import com.ritense.adminsettings.web.rest.dto.FeatureToggleOverridesDto import com.ritense.adminsettings.web.rest.dto.UpdateFeatureToggleDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -38,11 +39,19 @@ class FeatureToggleOverridesResource( private val featureToggleOverridesService: FeatureToggleOverridesService ) { + @EndpointDescription( + en = "Get feature toggle overrides", + nl = "Feature toggle overrides ophalen", + ) @GetMapping("/v1/admin-settings/feature-toggles") fun getOverrides(): ResponseEntity { return ResponseEntity.ok(featureToggleOverridesService.getOverrides()) } + @EndpointDescription( + en = "Update feature toggle", + nl = "Feature toggle bijwerken", + ) @PutMapping("/management/v1/admin-settings/feature-toggles") fun updateToggle( @Valid @RequestBody dto: UpdateFeatureToggleDto @@ -50,6 +59,10 @@ class FeatureToggleOverridesResource( return ResponseEntity.ok(featureToggleOverridesService.updateToggle(dto.key, dto.enabled)) } + @EndpointDescription( + en = "Remove feature toggle by key", + nl = "Feature toggle verwijderen op sleutel", + ) @DeleteMapping("/management/v1/admin-settings/feature-toggles/{key}") fun removeToggle( @PathVariable key: String diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResource.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResource.kt new file mode 100644 index 0000000000..173608ae79 --- /dev/null +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResource.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.web.rest + +import com.ritense.adminsettings.service.MenuConfigurationService +import com.ritense.adminsettings.web.rest.dto.MenuConfigurationDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@SkipComponentScan +@RequestMapping("/api", produces = [APPLICATION_JSON_UTF8_VALUE]) +class MenuConfigurationResource( + private val menuConfigurationService: MenuConfigurationService +) { + + @EndpointDescription( + en = "Get the application menu configuration", + nl = "Menuconfiguratie ophalen", + ) + @GetMapping("/v1/admin-settings/menu-configuration") + fun getMenuConfiguration(): ResponseEntity { + return ResponseEntity.ok(menuConfigurationService.getMenuConfiguration()) + } + + @EndpointDescription( + en = "Update the application menu configuration", + nl = "Menuconfiguratie bijwerken", + ) + @PutMapping("/management/v1/admin-settings/menu-configuration") + fun updateMenuConfiguration( + @Valid @RequestBody dto: MenuConfigurationDto + ): ResponseEntity { + return ResponseEntity.ok(menuConfigurationService.updateMenuConfiguration(dto.configuration)) + } +} diff --git a/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/dto/MenuConfigurationDto.kt b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/dto/MenuConfigurationDto.kt new file mode 100644 index 0000000000..724149a9e9 --- /dev/null +++ b/backend/admin-settings/src/main/kotlin/com/ritense/adminsettings/web/rest/dto/MenuConfigurationDto.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.web.rest.dto + +import com.fasterxml.jackson.databind.JsonNode + +/** + * Carries the menu structure as opaque JSON — the backend stores and returns it verbatim and never + * interprets it (the frontend owns the schema). + */ +data class MenuConfigurationDto( + val configuration: JsonNode +) diff --git a/backend/admin-settings/src/test/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResourceIT.kt b/backend/admin-settings/src/test/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResourceIT.kt new file mode 100644 index 0000000000..3032c2c286 --- /dev/null +++ b/backend/admin-settings/src/test/kotlin/com/ritense/adminsettings/web/rest/MenuConfigurationResourceIT.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.adminsettings.web.rest + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.adminsettings.BaseIntegrationTest +import com.ritense.adminsettings.repository.MenuConfigurationRepository +import com.ritense.adminsettings.service.MenuConfigurationService.Companion.MAX_CONFIGURATION_LENGTH +import com.ritense.valtimo.contract.authentication.AuthoritiesConstants.ADMIN +import com.ritense.valtimo.contract.authentication.AuthoritiesConstants.USER +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext + +class MenuConfigurationResourceIT : BaseIntegrationTest() { + + @Autowired + lateinit var webApplicationContext: WebApplicationContext + + @Autowired + lateinit var menuConfigurationRepository: MenuConfigurationRepository + + @Autowired + lateinit var objectMapper: ObjectMapper + + lateinit var mockMvc: MockMvc + + @BeforeEach + fun beforeEach() { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + } + + @AfterEach + fun afterEach() { + menuConfigurationRepository.deleteAll() + } + + @Test + fun `should get empty configuration when none exists`() { + mockMvc.perform( + get("/api/v1/admin-settings/menu-configuration") + .with(user("user").authorities(userAuthority())) + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.configuration").isMap) + .andExpect(jsonPath("$.configuration").isEmpty) + } + + @Test + fun `should update and round-trip the menu configuration`() { + val body = """{"configuration":{"version":1,"items":[{"kind":"catalog","itemId":"dashboard"}]}}""" + + mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .with(user("admin").authorities(adminAuthority())) + .contentType(APPLICATION_JSON_VALUE) + .content(body) + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.configuration.version").value(1)) + .andExpect(jsonPath("$.configuration.items[0].itemId").value("dashboard")) + + mockMvc.perform( + get("/api/v1/admin-settings/menu-configuration") + .with(user("user").authorities(userAuthority())) + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.configuration.version").value(1)) + .andExpect(jsonPath("$.configuration.items[0].kind").value("catalog")) + .andExpect(jsonPath("$.configuration.items[0].itemId").value("dashboard")) + } + + @Test + fun `should replace the configuration on update`() { + mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .with(user("admin").authorities(adminAuthority())) + .contentType(APPLICATION_JSON_VALUE) + .content("""{"configuration":{"version":1,"items":[{"kind":"catalog","itemId":"cases"}]}}""") + ) + .andExpect(status().isOk) + + mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .with(user("admin").authorities(adminAuthority())) + .contentType(APPLICATION_JSON_VALUE) + .content("""{"configuration":{"version":2,"items":[]}}""") + ) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.configuration.version").value(2)) + .andExpect(jsonPath("$.configuration.items").isEmpty) + } + + @Test + fun `should reject a configuration that exceeds the maximum size`() { + val oversizedValue = "x".repeat(MAX_CONFIGURATION_LENGTH + 1) + val body = """{"configuration":{"blob":"$oversizedValue"}}""" + + mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .with(user("admin").authorities(adminAuthority())) + .contentType(APPLICATION_JSON_VALUE) + .content(body) + ) + .andDo(print()) + .andExpect(status().isBadRequest) + + assertThat(menuConfigurationRepository.findAll()).isEmpty() + } + + @Test + fun `should deny update for non-admin users`() { + mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .with(user("user").authorities(userAuthority())) + .contentType(APPLICATION_JSON_VALUE) + .content("""{"configuration":{"version":1}}""") + ) + .andDo(print()) + .andExpect(status().isForbidden) + } + + @Test + fun `should deny access for unauthenticated requests`() { + val getStatus = mockMvc.perform( + get("/api/v1/admin-settings/menu-configuration") + ) + .andDo(print()) + .andReturn().response.status + + val putStatus = mockMvc.perform( + put("/api/management/v1/admin-settings/menu-configuration") + .contentType(APPLICATION_JSON_VALUE) + .content("""{"configuration":{"version":1}}""") + ) + .andDo(print()) + .andReturn().response.status + + assertThat(getStatus).isIn(401, 403) + assertThat(putStatus).isIn(401, 403) + } + + private fun adminAuthority() = SimpleGrantedAuthority(ADMIN) + + private fun userAuthority() = SimpleGrantedAuthority(USER) +} diff --git a/backend/apps/dev/.env.properties b/backend/apps/dev/.env.properties index ed4dd0da3a..8df25d64eb 100644 --- a/backend/apps/dev/.env.properties +++ b/backend/apps/dev/.env.properties @@ -1,3 +1,19 @@ +# +# Copyright 2015-2026 Ritense BV, the Netherlands. +# +# Licensed under EUPL, Version 1.2 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + # This file with secrets has been made public for demonstration purposes. SPRING_PROFILES_ACTIVE=dev @@ -64,6 +80,9 @@ VALTIMO_APP_HOSTNAME=http://localhost:4200/ VALTIMO_PLUGIN_ENCRYPTIONSECRET=abcdefghijklmnop +VALTIMO_EXTERNAL_PLUGIN_SERVICE_TOKEN_SECRET=0a083eee3aef3c064f0fc1acc5e67b4e536c8d62fea283e34d8478c46c043aab +VALTIMO_EXTERNAL_PLUGIN_GZAC_BASE_URL=http://localhost:8080 + VALTIMO_ZGW_ZAAKDETAILS_LINKTOZAAK_ENABLED=true LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BEANS=DEBUG diff --git a/backend/apps/dev/docker-compose.yaml b/backend/apps/dev/docker-compose.yaml index afaa1a1af0..45c12ef1bc 100644 --- a/backend/apps/dev/docker-compose.yaml +++ b/backend/apps/dev/docker-compose.yaml @@ -103,9 +103,11 @@ services: gzac-rabbitmq: image: rabbitmq:4.1.0-management container_name: gzac-docker-compose-gzac-rabbitmq + hostname: gzac-rabbitmq # stable Erlang node name (rabbit@gzac-rabbitmq) so durable streams/quorum queues survive restarts volumes: - ./imports/gzac-rabbitmq/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro - ./imports/gzac-rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro + - gzac-rabbitmq-data:/var/lib/rabbitmq # persist data even if container shuts down ports: - "5672:5672" - "15672:15672" @@ -751,4 +753,5 @@ services: volumes: gzac-database-data: gzac-database-data-mysql: + gzac-rabbitmq-data: gzac-opensearch-data: diff --git a/backend/apps/dev/src/main/resources/config/application.yml b/backend/apps/dev/src/main/resources/config/application.yml index 6dc3cec817..d26b52a88d 100644 --- a/backend/apps/dev/src/main/resources/config/application.yml +++ b/backend/apps/dev/src/main/resources/config/application.yml @@ -192,6 +192,10 @@ valtimo: plugin: encryption-secret: ${VALTIMO_PLUGIN_ENCRYPTIONSECRET} + external-plugin: + service-token: + ttl: PT12H # 12 hours + notificaties-api: processing: batch-size: 50 diff --git a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json index 88018bad81..1af6a9ecd5 100644 --- a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json +++ b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json @@ -119,11 +119,11 @@ "target": "doc:/zgwActionSummary" }, { - "source": "doc:/reviewerRemarks", + "source": "doc:/submission/reviewerRemarks", "target": "doc:/reviewerRemarks" }, { - "source": "doc:/approvalDecision", + "source": "doc:/submission/approvalDecision", "target": "doc:/approvalDecision" } ] diff --git a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json index 3dee5b1c49..d8a6754e8e 100644 --- a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json +++ b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json @@ -74,12 +74,12 @@ "syncTiming": "END" }, { - "source": "/reviewerRemarks", + "source": "/submission/reviewerRemarks", "target": "doc:/reviewerRemarks", "syncTiming": "CONTINUOUS" }, { - "source": "/approvalDecision", + "source": "/submission/approvalDecision", "target": "doc:/approvalDecision", "syncTiming": "CONTINUOUS" }, diff --git a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PbacRegistryResource.kt b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PbacRegistryResource.kt index 28fe55ffbd..2b523ebc5b 100644 --- a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PbacRegistryResource.kt +++ b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PbacRegistryResource.kt @@ -20,6 +20,7 @@ import com.ritense.authorization.PbacRegistryService import com.ritense.authorization.web.rest.dto.PbacRegistryDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -31,6 +32,10 @@ import org.springframework.web.bind.annotation.RestController class PbacRegistryResource( private val pbacRegistryService: PbacRegistryService, ) { + @EndpointDescription( + en = "Get the PBAC registry of resources, actions and available role authorizations", + nl = "Het PBAC-register met resources, acties en beschikbare rolautorisaties ophalen", + ) @GetMapping("/v1/pbac/registry") fun getRegistry(): ResponseEntity { return ResponseEntity.ok(pbacRegistryService.getRegistry()) diff --git a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionManagementResource.kt b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionManagementResource.kt index 591cd373c3..33339f4e40 100644 --- a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionManagementResource.kt +++ b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionManagementResource.kt @@ -21,6 +21,7 @@ import com.ritense.authorization.permission.PermissionRepository import com.ritense.authorization.web.request.SearchPermissionsRequest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -34,6 +35,10 @@ import org.springframework.web.bind.annotation.RequestMapping class PermissionManagementResource( val permissionRepository: PermissionRepository ) { + @EndpointDescription( + en = "Search permissions by roles", + nl = "Permissies zoeken op rollen", + ) @PostMapping("/v1/permissions/search") fun searchPermissions(@Valid @RequestBody searchRequest: SearchPermissionsRequest): ResponseEntity> { val rolePermissions = permissionRepository diff --git a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionResource.kt b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionResource.kt index 06c1f5a6ea..e6020f80c6 100644 --- a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionResource.kt +++ b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionResource.kt @@ -24,6 +24,7 @@ import com.ritense.authorization.web.request.PermissionAvailableRequest import com.ritense.authorization.web.result.PermissionAvailableResult import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -43,6 +44,10 @@ class PermissionResource( private val logger: Logger = LoggerFactory.getLogger(PermissionResource::class.java) + @EndpointDescription( + en = "Check user permissions", + nl = "Gebruikerspermissies controleren", + ) @Transactional(readOnly = true) @PostMapping("/v1/permissions") fun userHasPermission(@Valid @RequestBody permissionsPresentRequest: List) diff --git a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionSchemaResource.kt b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionSchemaResource.kt index 588caed50a..30df83e173 100644 --- a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionSchemaResource.kt +++ b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/PermissionSchemaResource.kt @@ -18,6 +18,7 @@ package com.ritense.authorization.web import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.core.io.ClassPathResource import org.springframework.core.io.Resource import org.springframework.http.ResponseEntity @@ -30,6 +31,10 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/management", produces = [ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE]) class PermissionSchemaResource { + @EndpointDescription( + en = "Get permission schema", + nl = "Permissieschema ophalen", + ) @GetMapping("/v1/permissions/schema") fun getSchema(): ResponseEntity = ResponseEntity.ok(ClassPathResource(SCHEMA_PATH)) diff --git a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/RoleManagementResource.kt b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/RoleManagementResource.kt index c6948a78cd..25f6d89636 100644 --- a/backend/authorization/src/main/kotlin/com/ritense/authorization/web/RoleManagementResource.kt +++ b/backend/authorization/src/main/kotlin/com/ritense/authorization/web/RoleManagementResource.kt @@ -31,6 +31,7 @@ import com.ritense.authorization.web.request.UpdateRoleRequest import com.ritense.authorization.web.result.RoleResult import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -52,12 +53,20 @@ class RoleManagementResource( val permissionRepository: PermissionRepository, val migrator: PermissionResourceTypeMigrator ) { + @EndpointDescription( + en = "List roles", + nl = "Rollen ophalen", + ) @GetMapping("/v1/roles") fun getRoles() : ResponseEntity> { return ResponseEntity.ok(roleRepository.findAll().map { RoleResult.fromRole(it) }) } + @EndpointDescription( + en = "Create role", + nl = "Rol aanmaken", + ) @PostMapping("/v1/roles") fun createRole(@Valid @RequestBody saveRoleRequest: SaveRoleRequest) : ResponseEntity { @@ -69,6 +78,10 @@ class RoleManagementResource( } } + @EndpointDescription( + en = "Update role by key", + nl = "Rol bijwerken op sleutel", + ) @PutMapping("/v1/roles/{oldRoleKey}") fun updateRole(@PathVariable oldRoleKey: String, @Valid @RequestBody updateRoleRequest: UpdateRoleRequest) : ResponseEntity { @@ -79,6 +92,10 @@ class RoleManagementResource( return ResponseEntity.ok(RoleResult.fromRole(role)) } + @EndpointDescription( + en = "Delete roles", + nl = "Rollen verwijderen", + ) @DeleteMapping("/v1/roles") @Transactional fun deleteRole(@Valid @RequestBody deleteRolesRequest: DeleteRolesRequest) @@ -89,6 +106,10 @@ class RoleManagementResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "List role permissions by key", + nl = "Rolpermissies ophalen op sleutel", + ) @GetMapping("/v1/roles/{roleKey}/permissions") @JsonView(PermissionView.RoleManagement::class) fun getRolePermissions(@PathVariable roleKey: String) @@ -107,6 +128,10 @@ class RoleManagementResource( return ResponseEntity.ok(rolePermissions) } + @EndpointDescription( + en = "Update role permissions by key", + nl = "Rolpermissies bijwerken op sleutel", + ) @PutMapping("/v1/roles/{roleKey}/permissions") @JsonView(PermissionView.RoleManagement::class) @Transactional diff --git a/backend/aws/s3-resource/src/main/kotlin/com/ritense/resource/web/rest/S3Resource.kt b/backend/aws/s3-resource/src/main/kotlin/com/ritense/resource/web/rest/S3Resource.kt index 3f197fa4b4..9b42dbe288 100644 --- a/backend/aws/s3-resource/src/main/kotlin/com/ritense/resource/web/rest/S3Resource.kt +++ b/backend/aws/s3-resource/src/main/kotlin/com/ritense/resource/web/rest/S3Resource.kt @@ -19,6 +19,7 @@ package com.ritense.resource.web.rest import com.ritense.resource.service.S3Service import com.ritense.resource.web.ObjectUrlDTO import com.ritense.resource.web.ResourceDTO +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -28,6 +29,10 @@ class S3Resource( private val s3Service: S3Service ) : ResourceResource { + @EndpointDescription( + en = "Generate pre-signed upload URL for file", + nl = "Vooraf ondertekende upload-URL voor bestand genereren", + ) @GetMapping(value = ["/v1/resource/pre-signed-url/{fileName}"], produces = ["text/plain;charset=UTF-8"]) fun generatePreSignedPutObjectUrlForFileName(@PathVariable(name = "fileName") fileName: String): ResponseEntity { return ResponseEntity.ok(s3Service.generatePreSignedPutObjectUrl(fileName).toString()) diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt index b0204e4355..6c41035db3 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt @@ -29,6 +29,8 @@ import com.ritense.buildingblock.processlink.mapper.BuildingBlockProcessLinkMapp import com.ritense.buildingblock.processlink.service.BuildingBlockCallActivityListener import com.ritense.buildingblock.processlink.service.BuildingBlockSupportedProcessLinksHandler import com.ritense.buildingblock.processlink.service.DefaultBuildingBlockPluginConfigurationResolver +import com.ritense.buildingblock.processlink.service.DefaultBuildingBlockPluginMappingUsageFinder +import com.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository import com.ritense.buildingblock.repository.BuildingBlockDefinitionArtworkRepository import com.ritense.buildingblock.repository.BuildingBlockDefinitionRepository import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository @@ -101,6 +103,7 @@ import com.ritense.formflow.service.FormFlowService import com.ritense.importer.ImportService import com.ritense.importer.ValtimoImportService import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder import com.ritense.plugin.service.PluginService import com.ritense.processdocument.service.BuildingBlockProcessLookup import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider @@ -506,6 +509,17 @@ class BuildingBlockAutoConfiguration { documentService, ) + @Bean + @ConditionalOnMissingBean(BuildingBlockPluginMappingUsageFinder::class) + fun buildingBlockPluginMappingUsageFinder( + buildingBlockProcessLinkRepository: BuildingBlockProcessLinkRepository, + caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + ): BuildingBlockPluginMappingUsageFinder = + DefaultBuildingBlockPluginMappingUsageFinder( + buildingBlockProcessLinkRepository, + caseDefinitionBuildingBlockLinkRepository, + ) + @Bean @ConditionalOnMissingBean(BuildingBlockCaseAssigneeListener::class) fun buildingBlockCaseAssigneeListener( diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt index 439cf6bbba..8537c9943e 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt @@ -38,18 +38,31 @@ class DefaultBuildingBlockPluginConfigurationResolver( private val documentService: DocumentService, ) : BuildingBlockPluginConfigurationResolver { - override fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? { - val instance = findInstance(execution) ?: return null - val root = findRootInstance(instance) - - return findCallActivityMapping(root, pluginDefinitionKey) - ?: findCaseLinkMapping(root, pluginDefinitionKey) - } + override fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? = + resolveMapping(execution) { it[pluginDefinitionKey] } override fun resolve(task: DelegateTask, pluginDefinitionKey: String): UUID? { return resolve(task.execution, pluginDefinitionKey) } + override fun resolveByKeyPrefix(execution: DelegateExecution, keyPrefix: String): UUID? = + resolveMapping(execution) { mappings -> + mappings.entries.firstOrNull { it.key.startsWith(keyPrefix) }?.value + } + + /** + * Selects a configuration id from the call-activity process link's mappings first, then the + * case-definition ↔ building-block link's mappings — the original resolution order — returning + * the first non-null. + */ + private fun resolveMapping(execution: DelegateExecution, select: (Map) -> UUID?): UUID? { + val instance = findInstance(execution) ?: return null + val root = findRootInstance(instance) + + return callActivityMappings(root)?.let(select) + ?: caseLinkMappings(root)?.let(select) + } + /** * Prefer the BB document id (Valtimo convention: business key == document id, propagated through * `` on call activities). This makes the resolver work from any @@ -78,10 +91,10 @@ class DefaultBuildingBlockPluginConfigurationResolver( } /** - * Resolves plugin configuration from the BuildingBlockProcessLink on the call activity - * that started the root building block. + * The plugin configuration mappings from the BuildingBlockProcessLink on the call activity that + * started the root building block. */ - private fun findCallActivityMapping(instance: BuildingBlockInstance, pluginDefinitionKey: String): UUID? { + private fun callActivityMappings(instance: BuildingBlockInstance): Map? { val activityId = instance.activityId ?: return null val callerProcessDefinitionId = instance.callerProcessDefinitionId ?: return null @@ -89,19 +102,16 @@ class DefaultBuildingBlockPluginConfigurationResolver( .filterIsInstance() .firstOrNull() ?.pluginConfigurationMappings - ?.get(pluginDefinitionKey) } - private fun findCaseLinkMapping(instance: BuildingBlockInstance, pluginDefinitionKey: String): UUID? { + private fun caseLinkMappings(instance: BuildingBlockInstance): Map? { val caseDocumentId = instance.caseDocumentId ?: return null val caseDocument = documentService.get(caseDocumentId.toString()) val caseDefinitionId = caseDocument.definitionId().caseDefinitionId() - val link = linkRepository.findByCaseDefinitionIdAndBuildingBlockDefinitionId( + return linkRepository.findByCaseDefinitionIdAndBuildingBlockDefinitionId( caseDefinitionId, instance.definition.id - ) ?: return null - - return link.pluginConfigurationMappings[pluginDefinitionKey] + )?.pluginConfigurationMappings } } diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt new file mode 100644 index 0000000000..f086333289 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.processlink.service + +import com.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.plugin.service.BuildingBlockPluginMappingUsage +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Component +@SkipComponentScan +@Transactional(readOnly = true) +class DefaultBuildingBlockPluginMappingUsageFinder( + private val processLinkRepository: BuildingBlockProcessLinkRepository, + private val caseLinkRepository: CaseDefinitionBuildingBlockLinkRepository, +) : BuildingBlockPluginMappingUsageFinder { + + /** + * The mappings live in JSON columns, so matching happens in memory — the platform supports + * both PostgreSQL and MySQL, whose JSON query dialects differ. Both tables hold + * configuration-time data (bounded by the number of linked activities / case-BB pairs), not + * runtime data, so a full scan stays small. + */ + override fun findUsages(configurationId: UUID): List { + val processLinkUsages = processLinkRepository.findAll().flatMap { link -> + link.pluginConfigurationMappings.filterValues { it == configurationId }.keys.map { mappingKey -> + BuildingBlockPluginMappingUsage( + mappingKey = mappingKey, + buildingBlockDefinitionKey = link.buildingBlockDefinitionId.key, + processLinkId = link.id, + processDefinitionId = link.processDefinitionId, + activityId = link.activityId, + ) + } + } + + val caseLinkUsages = caseLinkRepository.findAll().flatMap { caseLink -> + caseLink.pluginConfigurationMappings.filterValues { it == configurationId }.keys.map { mappingKey -> + BuildingBlockPluginMappingUsage( + mappingKey = mappingKey, + buildingBlockDefinitionKey = caseLink.buildingBlockDefinitionId.key, + caseDefinitionKey = caseLink.caseDefinitionId.key, + caseDefinitionVersionTag = caseLink.caseDefinitionId.versionTag.toString(), + ) + } + } + + return processLinkUsages + caseLinkUsages + } +} diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt new file mode 100644 index 0000000000..4446a6c844 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.repository + +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface BuildingBlockProcessLinkRepository : JpaRepository diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt index eedbbf8227..7cc439a486 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt @@ -20,6 +20,8 @@ import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository import com.ritense.plugin.service.PluginService import com.ritense.plugin.web.rest.result.PluginDefinitionsWithDependenciesDto +import com.ritense.plugin.web.rest.result.PluginRequirementSource +import com.ritense.plugin.web.rest.result.PluginWithDependenciesDto import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.processlink.service.ProcessLinkService import com.ritense.valtimo.contract.annotation.SkipComponentScan @@ -85,12 +87,70 @@ class BuildingBlockPluginDefinitionService( return keys.toSet() } + /** + * External plugin references (`external_plugin` process links with a `BUILDING_BLOCK` + * reference) required by a building block, keyed by `pluginId` + manifest version, including + * references from nested building blocks. + */ + fun getExternalPluginReferencesForBuildingBlock( + buildingBlockDefinitionId: BuildingBlockDefinitionId + ): Set> { + return getExternalPluginReferencesForBuildingBlockRecursive(buildingBlockDefinitionId, mutableSetOf()) + } + + private fun getExternalPluginReferencesForBuildingBlockRecursive( + buildingBlockDefinitionId: BuildingBlockDefinitionId, + visitedBuildingBlocks: MutableSet + ): Set> { + if (visitedBuildingBlocks.contains(buildingBlockDefinitionId)) { + return emptySet() + } + visitedBuildingBlocks.add(buildingBlockDefinitionId) + + val processDefinitionIds = processDefinitionBuildingBlockDefinitionRepository + .findAllByIdBuildingBlockDefinitionId(buildingBlockDefinitionId) + .map { it.id.processDefinitionId.id } + + if (processDefinitionIds.isEmpty()) { + return emptySet() + } + + val directReferences = pluginProcessLinkRepository + .findExternalPluginReferencesByProcessDefinitionIds(processDefinitionIds) + .map { it.getPluginDefinitionKey() to it.getPluginDefinitionVersion() } + .toSet() + + val nestedBuildingBlockDefinitionIds = processDefinitionIds.flatMap { processDefinitionId -> + processLinkService.getProcessLinks(processDefinitionId) + .filterIsInstance() + .map { it.buildingBlockDefinitionId } + }.toSet() + + val nestedReferences = nestedBuildingBlockDefinitionIds.flatMap { nestedBuildingBlockId -> + getExternalPluginReferencesForBuildingBlockRecursive(nestedBuildingBlockId, visitedBuildingBlocks) + }.toSet() + + return directReferences + nestedReferences + } + fun getPluginDefinitionsWithDependenciesForBuildingBlock( buildingBlockId: BuildingBlockDefinitionId ): PluginDefinitionsWithDependenciesDto { val pluginKeys = getPluginDefinitionKeysForBuildingBlock(buildingBlockId) + val embedded = pluginService.getPluginDefinitionsWithDependencies(pluginKeys) + + val externalReferences = getExternalPluginReferencesForBuildingBlock(buildingBlockId) + val external = externalReferences.map { (pluginId, version) -> + PluginWithDependenciesDto( + pluginDefinitionKey = pluginId, + dependencies = emptyList(), + source = PluginRequirementSource.EXTERNAL, + pluginDefinitionVersion = version, + ) + } - return pluginService - .getPluginDefinitionsWithDependencies(pluginKeys) + return PluginDefinitionsWithDependenciesDto( + plugins = embedded.plugins + external + ) } } diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDecisionManagementResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDecisionManagementResource.kt index 7417fe396d..3c1044b723 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDecisionManagementResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDecisionManagementResource.kt @@ -21,6 +21,7 @@ import com.ritense.buildingblock.service.BuildingBlockDecisionService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.web.rest.dto.DefinitionDeploymentResponseDto import org.operaton.bpm.engine.impl.persistence.entity.DeploymentEntity import org.operaton.bpm.engine.rest.dto.repository.DecisionDefinitionDto @@ -44,6 +45,10 @@ class BuildingBlockDecisionManagementResource( private val buildingBlockDecisionService: BuildingBlockDecisionService, ) { + @EndpointDescription( + en = "List building block decision definitions", + nl = "Beslisdefinities van bouwblok ophalen", + ) @GetMapping( value = ["/{key}/version/{versionTag}/decision-definition"], produces = [MediaType.APPLICATION_JSON_VALUE] @@ -63,6 +68,10 @@ class BuildingBlockDecisionManagementResource( }) } + @EndpointDescription( + en = "Deploy building block decision definition", + nl = "Beslisdefinitie van bouwblok uitrollen", + ) @PostMapping( value = ["/{key}/version/{versionTag}/decision-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -96,6 +105,10 @@ class BuildingBlockDecisionManagementResource( ) } + @EndpointDescription( + en = "Delete building block decision definition", + nl = "Beslisdefinitie van bouwblok verwijderen", + ) @DeleteMapping( value = ["/{key}/version/{versionTag}/decision-definition/{decisionDefinitionKey}"], ) diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDefinitionArtworkResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDefinitionArtworkResource.kt index 7aa228762e..d8c7431a22 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDefinitionArtworkResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDefinitionArtworkResource.kt @@ -22,6 +22,7 @@ import com.ritense.buildingblock.web.rest.dto.BuildingBlockDefinitionArtworkDto import com.ritense.buildingblock.web.rest.dto.CreateBuildingBlockDefinitionArtworkDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -39,6 +40,10 @@ class BuildingBlockDefinitionArtworkResource( private val buildingBlockDefinitionArtworkService: BuildingBlockDefinitionArtworkService ) { + @EndpointDescription( + en = "Get building block artwork", + nl = "Bouwblokafbeelding ophalen", + ) @GetMapping("/{key}/version/{versionTag}/artwork") fun getArtwork( @PathVariable key: String, @@ -49,6 +54,10 @@ class BuildingBlockDefinitionArtworkResource( ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Create building block artwork", + nl = "Bouwblokafbeelding aanmaken", + ) @PostMapping( path = ["/{key}/version/{versionTag}/artwork"], consumes = [APPLICATION_JSON_UTF8_VALUE] @@ -63,6 +72,10 @@ class BuildingBlockDefinitionArtworkResource( return ResponseEntity.ok(created) } + @EndpointDescription( + en = "Delete building block artwork", + nl = "Bouwblokafbeelding verwijderen", + ) @DeleteMapping("/{key}/version/{versionTag}/artwork") fun deleteArtwork( @PathVariable key: String, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDocumentDefinitionResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDocumentDefinitionResource.kt index 04a501f8f1..f848131f5a 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDocumentDefinitionResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockDocumentDefinitionResource.kt @@ -27,6 +27,7 @@ import com.ritense.document.service.impl.JsonSchemaDocumentDefinitionService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.GetMapping @@ -46,6 +47,10 @@ class BuildingBlockDocumentDefinitionResource( private val mapper: ObjectMapper ) { + @EndpointDescription( + en = "Get building block document definition", + nl = "Documentdefinitie van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/document") fun getDocumentDefinition( @PathVariable key: String, @@ -60,6 +65,10 @@ class BuildingBlockDocumentDefinitionResource( return ResponseEntity.ok(definition.schema()) } + @EndpointDescription( + en = "Update building block document definition", + nl = "Documentdefinitie van bouwblok bijwerken", + ) @PutMapping("/{key}/version/{versionTag}/document", consumes = [APPLICATION_JSON_UTF8_VALUE]) @Transactional fun updateDocumentDefinition( diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFieldResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFieldResource.kt index 31791a5a75..76fadf84ac 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFieldResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFieldResource.kt @@ -21,6 +21,7 @@ import com.ritense.buildingblock.service.BuildingBlockFieldService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -34,6 +35,10 @@ class BuildingBlockFieldResource( private val fieldService: BuildingBlockFieldService ) { + @EndpointDescription( + en = "List building block fields", + nl = "Bouwblokvelden ophalen", + ) @GetMapping("/{key}/version/{versionTag}/fields") fun getFields( @PathVariable key: String, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormFlowManagementResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormFlowManagementResource.kt index 9885173c88..9b28457a3a 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormFlowManagementResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormFlowManagementResource.kt @@ -22,6 +22,7 @@ import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionChecker import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -45,6 +46,10 @@ class BuildingBlockFormFlowManagementResource( private val buildingBlockDefinitionChecker: BuildingBlockDefinitionChecker, ) { + @EndpointDescription( + en = "List building block form flow definitions", + nl = "Form flow-definities van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/form-flow-definition") @Transactional fun getAllFormFlowDefinitions( @@ -64,6 +69,10 @@ class BuildingBlockFormFlowManagementResource( return ResponseEntity.ok(definitions) } + @EndpointDescription( + en = "Get building block form flow definition", + nl = "Form flow-definitie van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun getFormFlowDefinition( @@ -82,6 +91,10 @@ class BuildingBlockFormFlowManagementResource( ) } + @EndpointDescription( + en = "Create building block form flow definition", + nl = "Form flow-definitie van bouwblok aanmaken", + ) @PostMapping("/{key}/version/{versionTag}/form-flow-definition") @Transactional fun createFormFlowDefinition( @@ -98,6 +111,10 @@ class BuildingBlockFormFlowManagementResource( return ResponseEntity.ok(FormFlowDefinitionDto.of(saved, false)) } + @EndpointDescription( + en = "Update building block form flow definition", + nl = "Form flow-definitie van bouwblok bijwerken", + ) @PutMapping("/{key}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun updateFormFlowDefinition( @@ -117,6 +134,10 @@ class BuildingBlockFormFlowManagementResource( return ResponseEntity.ok(FormFlowDefinitionDto.of(saved, false)) } + @EndpointDescription( + en = "Delete building block form flow definition", + nl = "Form flow-definitie van bouwblok verwijderen", + ) @DeleteMapping("/{key}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun deleteFormFlowDefinition( diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormManagementResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormManagementResource.kt index 91587a5df5..643fcc3c88 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormManagementResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockFormManagementResource.kt @@ -25,6 +25,7 @@ import com.ritense.form.web.rest.dto.FormOption import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -47,6 +48,10 @@ class BuildingBlockFormManagementResource( private val buildingBlockFormDefinitionService: BuildingBlockFormDefinitionService ) { + @EndpointDescription( + en = "List building block form options", + nl = "Formulieropties van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/form-option") fun getFormOptions( @PathVariable key: String, @@ -58,6 +63,10 @@ class BuildingBlockFormManagementResource( ) } + @EndpointDescription( + en = "List building block form definitions", + nl = "Formulierdefinities van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/form") fun getFormDefinitions( @PathVariable key: String, @@ -71,6 +80,10 @@ class BuildingBlockFormManagementResource( ) } + @EndpointDescription( + en = "Get building block form definition", + nl = "Formulierdefinitie van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/form/{formDefinitionId}") fun getFormDefinition( @PathVariable key: String, @@ -84,6 +97,10 @@ class BuildingBlockFormManagementResource( return ResponseEntity.ok(BuildingBlockFormDefinitionDto.from(form)) } + @EndpointDescription( + en = "Get building block form definition by name", + nl = "Formulierdefinitie van bouwblok ophalen op naam", + ) @GetMapping("/{key}/version/{versionTag}/form/name/{name}") fun getFormDefinitionByName( @PathVariable key: String, @@ -97,6 +114,10 @@ class BuildingBlockFormManagementResource( return ResponseEntity.ok(BuildingBlockFormDefinitionDto.from(form)) } + @EndpointDescription( + en = "Check if building block form definition exists", + nl = "Controleren of formulierdefinitie van bouwblok bestaat", + ) @GetMapping("/{key}/version/{versionTag}/form/{name}/exists") fun formDefinitionExists( @PathVariable key: String, @@ -109,6 +130,10 @@ class BuildingBlockFormManagementResource( ) } + @EndpointDescription( + en = "Create building block form definition", + nl = "Formulierdefinitie van bouwblok aanmaken", + ) @PostMapping("/{key}/version/{versionTag}/form") fun createFormDefinition( @PathVariable key: String, @@ -125,6 +150,10 @@ class BuildingBlockFormManagementResource( return ResponseEntity.ok(BuildingBlockFormDefinitionDto.from(form)) } + @EndpointDescription( + en = "Update building block form definition", + nl = "Formulierdefinitie van bouwblok bijwerken", + ) @PutMapping("/{key}/version/{versionTag}/form/{formDefinitionId}") fun updateFormDefinition( @PathVariable key: String, @@ -142,6 +171,10 @@ class BuildingBlockFormManagementResource( return ResponseEntity.ok(BuildingBlockFormDefinitionDto.from(form)) } + @EndpointDescription( + en = "Delete building block form definition", + nl = "Formulierdefinitie van bouwblok verwijderen", + ) @DeleteMapping("/{key}/version/{versionTag}/form/{formDefinitionId}") fun deleteFormDefinition( @PathVariable key: String, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockInstanceResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockInstanceResource.kt index 0b9586058c..3d1271d30b 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockInstanceResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockInstanceResource.kt @@ -28,6 +28,7 @@ import com.ritense.document.service.JsonSchemaDocumentActionProvider import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -46,6 +47,10 @@ class BuildingBlockInstanceResource( private val authorizationService: AuthorizationService, ) { + @EndpointDescription( + en = "List building block instances for case", + nl = "Bouwblokinstanties voor dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/building-blocks") fun getInstancesForCase( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockManagementResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockManagementResource.kt index d8fde73f4a..83c71d7a60 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockManagementResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockManagementResource.kt @@ -34,6 +34,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -62,6 +63,10 @@ class BuildingBlockManagementResource( private val importService: ImportService, private val exportService: ExportService, ) { + @EndpointDescription( + en = "List building block definitions", + nl = "Bouwblokdefinities ophalen", + ) @GetMapping fun getBuildingBlockDefinitions( @RequestParam(value = "includeArtwork", required = false) includeArtwork: Boolean = false, @@ -74,6 +79,10 @@ class BuildingBlockManagementResource( } } + @EndpointDescription( + en = "Create building block definition", + nl = "Bouwblokdefinitie aanmaken", + ) @PostMapping(consumes = [APPLICATION_JSON_UTF8_VALUE]) fun createBuildingBlockDefinition( @Valid @RequestBody dto: CreateBuildingBlockDefinitionDto @@ -82,6 +91,10 @@ class BuildingBlockManagementResource( return ResponseEntity.ok(savedDto) } + @EndpointDescription( + en = "Get building block definition", + nl = "Bouwblokdefinitie ophalen", + ) @GetMapping("/{key}/version/{versionTag}") fun getBuildingBlockDefinition( @PathVariable key: String, @@ -93,6 +106,10 @@ class BuildingBlockManagementResource( ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Update building block definition", + nl = "Bouwblokdefinitie bijwerken", + ) @PutMapping("/{key}/version/{versionTag}", consumes = [APPLICATION_JSON_UTF8_VALUE]) fun updateBuildingBlockDefinition( @PathVariable key: String, @@ -103,6 +120,10 @@ class BuildingBlockManagementResource( return ResponseEntity.ok(updated) } + @EndpointDescription( + en = "Finalize building block definition", + nl = "Bouwblokdefinitie definitief maken", + ) @PostMapping("/{key}/version/{versionTag}/finalize") fun finalizeBuildingBlockDefinition( @PathVariable key: String, @@ -112,6 +133,10 @@ class BuildingBlockManagementResource( return ResponseEntity.ok(finalized) } + @EndpointDescription( + en = "Create building block draft", + nl = "Bouwblokconcept aanmaken", + ) @PostMapping("/{key}/version/{versionTag}/draft", consumes = [APPLICATION_JSON_UTF8_VALUE]) fun createDraftBuildingBlockDefinition( @PathVariable key: String, @@ -124,6 +149,10 @@ class BuildingBlockManagementResource( return ResponseEntity.ok(draft) } + @EndpointDescription( + en = "Import building block definition", + nl = "Bouwblokdefinitie importeren", + ) @PostMapping("/import") @RunWithoutAuthorization fun import( @@ -141,6 +170,10 @@ class BuildingBlockManagementResource( } } + @EndpointDescription( + en = "Export building block definition", + nl = "Bouwblokdefinitie exporteren", + ) @GetMapping( "/{key}/version/{versionTag}/export", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE] @@ -161,6 +194,10 @@ class BuildingBlockManagementResource( .body(baos.toByteArray()) } + @EndpointDescription( + en = "List building block definition versions", + nl = "Bouwblokdefinitieversies ophalen", + ) @GetMapping("/{key}/version") fun getBuildingBlockDefinitionVersions( @PathVariable key: String, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockProcessResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockProcessResource.kt index 56697b4934..646fe3104a 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockProcessResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockProcessResource.kt @@ -26,6 +26,7 @@ import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -50,6 +51,10 @@ class BuildingBlockProcessResource( private val buildingBlockPluginDefinitionService: BuildingBlockPluginDefinitionService, ) { + @EndpointDescription( + en = "Check if process is a building block", + nl = "Controleren of proces een bouwblok is", + ) @GetMapping("/process-definition/{processDefinitionId}/is-building-block") fun isBuildingBlockProcess( @PathVariable processDefinitionId: String @@ -60,6 +65,10 @@ class BuildingBlockProcessResource( return ResponseEntity.ok(result) } + @EndpointDescription( + en = "List building block process definitions", + nl = "Procesdefinities van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/process-definition") fun getProcessDefinitionsForBuildingBlock( @PathVariable key: String, @@ -74,6 +83,10 @@ class BuildingBlockProcessResource( return ResponseEntity.ok(items) } + @EndpointDescription( + en = "Get building block process definition with process links", + nl = "Procesdefinitie van bouwblok met proceskoppelingen ophalen", + ) @GetMapping("/{key}/version/{versionTag}/process-definition/{processDefinitionId}") fun getProcessDefinitionWithLinksForBuildingBlock( @PathVariable key: String, @@ -90,6 +103,10 @@ class BuildingBlockProcessResource( return dto?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Create building block process definition", + nl = "Procesdefinitie van bouwblok aanmaken", + ) @PostMapping( value = ["/{key}/version/{versionTag}/process-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -118,6 +135,10 @@ class BuildingBlockProcessResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Update building block process definition", + nl = "Procesdefinitie van bouwblok bijwerken", + ) @PutMapping( value = ["/{key}/version/{versionTag}/process-definition/{processDefinitionId}"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -148,6 +169,10 @@ class BuildingBlockProcessResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "List building block plugin definitions", + nl = "Plugindefinities van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/plugin") fun getPluginDefinitionsForBuildingBlock( @PathVariable key: String, @@ -161,6 +186,10 @@ class BuildingBlockProcessResource( return ResponseEntity.ok(pluginKeysWithDependencies) } + @EndpointDescription( + en = "List plugin keys for building block process definition", + nl = "Pluginsleutels voor procesdefinitie van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/process-definition/{processDefinitionId}/plugin") fun getPluginDefinitionsForProcessDefinition( @PathVariable key: String, @@ -173,6 +202,10 @@ class BuildingBlockProcessResource( return ResponseEntity.ok(pluginKeys.toList().sorted()) } + @EndpointDescription( + en = "Get main building block process definition key", + nl = "Hoofdprocesdefinitiesleutel van bouwblok ophalen", + ) @GetMapping("/{key}/version/{versionTag}/process-definition/main/key") fun getMainProcessDefinitionKeyForBuildingBlock( @PathVariable key: String, @@ -188,6 +221,10 @@ class BuildingBlockProcessResource( return mainKey?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Set main building block process definition", + nl = "Hoofdprocesdefinitie van bouwblok instellen", + ) @PostMapping("/{key}/version/{versionTag}/process-definition/{processDefinitionId}/main") fun setMainProcessDefinitionForBuildingBlock( @PathVariable key: String, @@ -205,6 +242,10 @@ class BuildingBlockProcessResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Delete building block process definition", + nl = "Procesdefinitie van bouwblok verwijderen", + ) @DeleteMapping("/{key}/version/{versionTag}/process-definition/{processDefinitionId}") fun deleteProcessDefinitionForBuildingBlock( @PathVariable key: String, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockValueResolverResource.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockValueResolverResource.kt index 996cdc1e3a..8b0d951ee4 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockValueResolverResource.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/web/rest/BuildingBlockValueResolverResource.kt @@ -19,6 +19,7 @@ package com.ritense.buildingblock.web.rest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valueresolver.ValueResolverOption import com.ritense.valueresolver.ValueResolverOptionRequest import com.ritense.valueresolver.ValueResolverService @@ -37,6 +38,10 @@ class BuildingBlockValueResolverResource( private val valueResolverService: ValueResolverService ) { + @EndpointDescription( + en = "List building block value resolver keys", + nl = "Sleutels van waarde-resolver van bouwblok ophalen", + ) @PostMapping("/management/v1/value-resolver/building-block/{buildingBlockDefinitionKey}/version/{buildingBlockDefinitionVersionTag}/keys") fun getResolvableKeys( @PathVariable buildingBlockDefinitionKey: String, diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt new file mode 100644 index 0000000000..d97fb9510b --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.processlink.domain + +import com.ritense.buildingblock.BaseIntegrationTest +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.repository.ProcessLinkRepository +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Round-trips a [BuildingBlockProcessLink] through the database — insert, update, and reload — for + * both a link that carries a plugin configuration mapping and one that does not. It guards the + * `@SecondaryTable` `building_block_process_link` row whose `input_mappings` / `output_mappings` / + * `plugin_configuration_mappings` are `jsonb`; that row is what a case-definition import using a + * plugin configuration in a building-block mapping persists. + * + * Context: on Hibernate 6.6 the row was written through the optional-secondary-table upsert `MERGE`, + * which mis-bound the json columns as `integer` ("column input_mappings is of type jsonb but + * expression is of type integer"). [BuildingBlockProcessLink] now declares `@SecondaryRow` + * (optional = false) so the row is always written with a plain INSERT/UPDATE. + * + * NB: this test exercises the same columns/entity but does not by itself reproduce that MERGE — the + * failing statement is only emitted by the static-update autoflush the full import/deploy path + * triggers (see BuildingBlockProcessLink's `@SecondaryRow` note); repository save/merge here binds + * the json correctly regardless of the flag. It is a persistence round-trip guard, not a strict + * regression test for the Hibernate binding. + */ +@Transactional +class BuildingBlockProcessLinkPersistenceIT @Autowired constructor( + private val processLinkRepository: ProcessLinkRepository, +) : BaseIntegrationTest() { + + @PersistenceContext + lateinit var entityManager: EntityManager + + @Test + fun `inserts and updates a building-block process link that has a plugin configuration mapping`() { + assertInsertAndUpdateRoundTrips( + initialMappings = mapOf("external-plugin:case-summary@0.1.0" to UUID.randomUUID()), + updatedMappings = mapOf("external-plugin:case-summary@0.1.0" to UUID.randomUUID()), + ) + } + + @Test + fun `inserts and updates a building-block process link without a plugin configuration mapping`() { + assertInsertAndUpdateRoundTrips( + initialMappings = emptyMap(), + updatedMappings = emptyMap(), + ) + } + + private fun assertInsertAndUpdateRoundTrips( + initialMappings: Map, + updatedMappings: Map, + ) { + val id = UUID.randomUUID() + val processDefinitionId = "energy-subsidy-request:1:${UUID.randomUUID()}" + val buildingBlockDefinitionId = BuildingBlockDefinitionId.of("subsidy-calculator", "1.0.0") + + // INSERT the secondary row. + processLinkRepository.saveAndFlush( + buildingBlockProcessLink(id, processDefinitionId, buildingBlockDefinitionId, initialMappings, "doc:/before") + ) + + // UPDATE the secondary row (the mappings change), then reload and assert the json round-trips. + processLinkRepository.saveAndFlush( + buildingBlockProcessLink(id, processDefinitionId, buildingBlockDefinitionId, updatedMappings, "doc:/after") + ) + entityManager.clear() + + val reloaded = processLinkRepository.findById(id).orElseThrow() as BuildingBlockProcessLink + assertThat(reloaded.pluginConfigurationMappings).isEqualTo(updatedMappings) + assertThat(reloaded.inputMappings).containsExactly( + BuildingBlockInputMapping(source = "doc:/after", target = "target") + ) + assertThat(reloaded.outputMappings).containsExactly( + BuildingBlockOutputMapping(source = "result", target = "doc:/result", syncTiming = BuildingBlockSyncTiming.END) + ) + } + + private fun buildingBlockProcessLink( + id: UUID, + processDefinitionId: String, + buildingBlockDefinitionId: BuildingBlockDefinitionId, + pluginConfigurationMappings: Map, + inputSource: String, + ) = BuildingBlockProcessLink( + id = id, + processDefinitionId = processDefinitionId, + activityId = "callActivity", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = buildingBlockDefinitionId, + pluginConfigurationMappings = pluginConfigurationMappings, + inputMappings = listOf(BuildingBlockInputMapping(source = inputSource, target = "target")), + outputMappings = listOf( + BuildingBlockOutputMapping(source = "result", target = "doc:/result", syncTiming = BuildingBlockSyncTiming.END) + ), + ) +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt index 5e4ab02797..e7c9063eb8 100644 --- a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt @@ -250,6 +250,43 @@ class DefaultBuildingBlockPluginConfigurationResolverIT @Autowired constructor( assertThat(resolvedConfigId).isEqualTo(pluginConfigurationId) } + @Test + fun `resolveByKeyPrefix matches a mapping made for a different version of the plugin`() { + // The building block's mapping was made for case-summary@0.1.0... + val bb1ProcessLink = BuildingBlockProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "case-process", + activityId = "callBB1", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = bb1DefinitionId, + pluginConfigurationMappings = mapOf("external-plugin:case-summary@0.1.0" to pluginConfigurationId), + inputMappings = emptyList() + ) + + whenever(processLinkService.getProcessLinks("case-process", "callBB1")).thenReturn(listOf(bb1ProcessLink)) + + val bb1Execution = createMockExecution( + processDefinitionId = "case-process", + activityId = "callBB1", + businessKey = caseDocumentId.toString() + ) + runWithoutAuthorization { + listener.onCallActivityStart(OperatonExecutionEvent(bb1Execution)) + } + val bb1Instance = buildingBlockInstanceRepository.findAll().first() + bb1Instance.processInstanceId = "bb1-process-instance" + buildingBlockInstanceService.save(bb1Instance) + + val bb1ProcessExecution = mock { + on { processInstanceId } doReturn "bb1-process-instance" + } + + // ...so an exact lookup for 0.2.0 finds nothing, but the version-agnostic prefix does. + assertThat(resolver.resolve(bb1ProcessExecution, "external-plugin:case-summary@0.2.0")).isNull() + assertThat(resolver.resolveByKeyPrefix(bb1ProcessExecution, "external-plugin:case-summary@")) + .isEqualTo(pluginConfigurationId) + } + private fun createMockExecution( processDefinitionId: String, activityId: String, diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt new file mode 100644 index 0000000000..df9c59b763 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.processlink.service + +import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import com.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.UUID + +class DefaultBuildingBlockPluginMappingUsageFinderTest { + + private lateinit var processLinkRepository: BuildingBlockProcessLinkRepository + private lateinit var caseLinkRepository: CaseDefinitionBuildingBlockLinkRepository + private lateinit var finder: DefaultBuildingBlockPluginMappingUsageFinder + + private val configurationId = UUID.randomUUID() + private val otherConfigurationId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + processLinkRepository = mock() + caseLinkRepository = mock() + whenever(processLinkRepository.findAll()).thenReturn(emptyList()) + whenever(caseLinkRepository.findAll()).thenReturn(emptyList()) + finder = DefaultBuildingBlockPluginMappingUsageFinder(processLinkRepository, caseLinkRepository) + } + + @Test + fun `reports a call-activity process link whose mappings reference the configuration`() { + val link = processLink( + mappings = mapOf( + "external-plugin:case-summary@0.1.0" to configurationId, + "zakenapi" to otherConfigurationId, + ) + ) + whenever(processLinkRepository.findAll()).thenReturn(listOf(link)) + + val usages = finder.findUsages(configurationId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.mappingKey).isEqualTo("external-plugin:case-summary@0.1.0") + assertThat(usage.buildingBlockDefinitionKey).isEqualTo("send-notification") + assertThat(usage.processLinkId).isEqualTo(link.id) + assertThat(usage.processDefinitionId).isEqualTo("bezwaar:3:abc") + assertThat(usage.activityId).isEqualTo("CallSendNotification") + assertThat(usage.caseDefinitionKey).isNull() + } + + @Test + fun `reports a case-definition link whose mappings reference the configuration`() { + val caseLink = CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId("bezwaar", "1.0.1"), + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mapOf("external-plugin:case-summary@0.1.0" to configurationId), + ) + whenever(caseLinkRepository.findAll()).thenReturn(listOf(caseLink)) + + val usages = finder.findUsages(configurationId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.mappingKey).isEqualTo("external-plugin:case-summary@0.1.0") + assertThat(usage.buildingBlockDefinitionKey).isEqualTo("send-notification") + assertThat(usage.caseDefinitionKey).isEqualTo("bezwaar") + assertThat(usage.caseDefinitionVersionTag).isEqualTo("1.0.1") + assertThat(usage.processDefinitionId).isNull() + } + + @Test + fun `mappings referencing other configurations are not reported`() { + whenever(processLinkRepository.findAll()).thenReturn( + listOf(processLink(mappings = mapOf("zakenapi" to otherConfigurationId))) + ) + whenever(caseLinkRepository.findAll()).thenReturn( + listOf( + CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId("bezwaar", "1.0.1"), + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mapOf("zakenapi" to otherConfigurationId), + ) + ) + ) + + assertThat(finder.findUsages(configurationId)).isEmpty() + } + + private fun processLink(mappings: Map): BuildingBlockProcessLink = BuildingBlockProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "bezwaar:3:abc", + activityId = "CallSendNotification", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mappings, + ) +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt new file mode 100644 index 0000000000..e90ae019a2 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository +import com.ritense.plugin.service.PluginService +import com.ritense.plugin.web.rest.result.PluginDefinitionsWithDependenciesDto +import com.ritense.plugin.web.rest.result.PluginRequirementSource +import com.ritense.plugin.web.rest.result.PluginWithDependenciesDto +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processlink.repository.ExternalPluginReferenceProjection +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BuildingBlockPluginDefinitionServiceTest { + + private lateinit var pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository + private lateinit var processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository + private lateinit var pluginService: PluginService + private lateinit var processLinkService: ProcessLinkService + private lateinit var service: BuildingBlockPluginDefinitionService + + private val buildingBlockId = BuildingBlockDefinitionId.of("my-building-block", "1.0.0") + private val processDefinitionId = "my-building-block-process:1:abc" + + @BeforeEach + fun setUp() { + pluginProcessLinkRepository = mock() + processDefinitionBuildingBlockDefinitionRepository = mock() + pluginService = mock() + processLinkService = mock() + service = BuildingBlockPluginDefinitionService( + pluginProcessLinkRepository, + processDefinitionBuildingBlockDefinitionRepository, + pluginService, + processLinkService, + ) + + whenever(processDefinitionBuildingBlockDefinitionRepository.findAllByIdBuildingBlockDefinitionId(buildingBlockId)) + .thenReturn( + listOf( + ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of(processDefinitionId), + buildingBlockDefinitionId = buildingBlockId, + ), + main = true, + ) + ) + ) + whenever(processLinkService.getProcessLinks(processDefinitionId)).thenReturn(emptyList()) + } + + @Test + fun `combines embedded and external plugin requirements with a source discriminator`() { + whenever(pluginProcessLinkRepository.findPluginDefinitionKeysByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(listOf("embedded-plugin")) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(listOf(referenceProjection("case-summary", "0.1.0"))) + whenever(pluginService.getPluginDefinitionsWithDependencies(setOf("embedded-plugin"))).thenReturn( + PluginDefinitionsWithDependenciesDto( + plugins = listOf( + PluginWithDependenciesDto( + pluginDefinitionKey = "embedded-plugin", + dependencies = emptyList(), + ) + ) + ) + ) + + val result = service.getPluginDefinitionsWithDependenciesForBuildingBlock(buildingBlockId) + + assertThat(result.plugins).hasSize(2) + assertThat(result.plugins).anySatisfy { plugin -> + assertThat(plugin.pluginDefinitionKey).isEqualTo("embedded-plugin") + assertThat(plugin.source).isEqualTo(PluginRequirementSource.EMBEDDED) + assertThat(plugin.pluginDefinitionVersion).isNull() + } + assertThat(result.plugins).anySatisfy { plugin -> + assertThat(plugin.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(plugin.source).isEqualTo(PluginRequirementSource.EXTERNAL) + assertThat(plugin.pluginDefinitionVersion).isEqualTo("0.1.0") + } + } + + @Test + fun `external plugin requirements are collected recursively through nested building blocks`() { + val nestedBuildingBlockId = BuildingBlockDefinitionId.of("nested-building-block", "2.0.0") + val nestedProcessDefinitionId = "nested-building-block-process:1:def" + + whenever(pluginProcessLinkRepository.findPluginDefinitionKeysByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(emptyList()) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(emptyList()) + whenever(pluginService.getPluginDefinitionsWithDependencies(emptySet())) + .thenReturn(PluginDefinitionsWithDependenciesDto(plugins = emptyList())) + + val nestedLink = mock { + on { this.buildingBlockDefinitionId } doReturn nestedBuildingBlockId + } + whenever(processLinkService.getProcessLinks(processDefinitionId)).thenReturn(listOf(nestedLink)) + + whenever(processDefinitionBuildingBlockDefinitionRepository.findAllByIdBuildingBlockDefinitionId(nestedBuildingBlockId)) + .thenReturn( + listOf( + ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of(nestedProcessDefinitionId), + buildingBlockDefinitionId = nestedBuildingBlockId, + ), + main = true, + ) + ) + ) + whenever(processLinkService.getProcessLinks(nestedProcessDefinitionId)).thenReturn(emptyList()) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(nestedProcessDefinitionId))) + .thenReturn(listOf(referenceProjection("nested-plugin", "1.2.3"))) + + val references = service.getExternalPluginReferencesForBuildingBlock(buildingBlockId) + + assertThat(references).containsExactly("nested-plugin" to "1.2.3") + } + + private fun referenceProjection(pluginId: String, version: String): ExternalPluginReferenceProjection = + object : ExternalPluginReferenceProjection { + override fun getPluginDefinitionKey() = pluginId + override fun getPluginDefinitionVersion() = version + } +} diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/DocumentOpenSearchReindexResource.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/DocumentOpenSearchReindexResource.kt index 693d771f07..157f003d26 100644 --- a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/DocumentOpenSearchReindexResource.kt +++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/DocumentOpenSearchReindexResource.kt @@ -18,6 +18,7 @@ package com.ritense.document.opensearch.web import com.ritense.document.opensearch.service.DocumentOpenSearchReindexService import com.ritense.document.opensearch.service.ReindexRequest +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.http.ResponseEntity @@ -36,6 +37,10 @@ class DocumentOpenSearchReindexResource( private val reindexService: DocumentOpenSearchReindexService, ) { + @EndpointDescription( + en = "Start document re-index", + nl = "Herindexering van documenten starten", + ) @PostMapping("/reindex") fun reindex(@RequestBody(required = false) request: ReindexRequest?): ResponseEntity> { val runId = reindexService.start(request ?: ReindexRequest()) @@ -43,6 +48,10 @@ class DocumentOpenSearchReindexResource( return ResponseEntity.accepted().body(mapOf("status" to "started", "runId" to runId)) } + @EndpointDescription( + en = "List document re-index runs", + nl = "Herindexeringsruns van documenten ophalen", + ) @GetMapping("/reindex/runs") fun listRuns( @RequestParam(defaultValue = "0") page: Int, @@ -50,9 +59,17 @@ class DocumentOpenSearchReindexResource( ): ResponseEntity>> = ResponseEntity.ok(reindexService.listRuns(PageRequest.of(page, size))) + @EndpointDescription( + en = "Get document re-index status", + nl = "Status van documentherindexering ophalen", + ) @GetMapping("/reindex/status") fun status(): ResponseEntity> = ResponseEntity.ok(reindexService.status()) + @EndpointDescription( + en = "Get document re-index run by id", + nl = "Herindexeringsrun van documenten ophalen op id", + ) @GetMapping("/reindex/{runId}") fun statusById(@PathVariable runId: UUID): ResponseEntity> = ResponseEntity.ok(reindexService.status(runId)) diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/SearchEngineResource.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/SearchEngineResource.kt index afead41d82..bc2a73acbc 100644 --- a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/SearchEngineResource.kt +++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/web/SearchEngineResource.kt @@ -21,6 +21,7 @@ import com.ritense.document.opensearch.OpenSearchProperties import com.ritense.document.opensearch.autoconfigure.DocumentOpenSearchAutoConfiguration.Companion.SEARCH_ENGINE_TOGGLE_KEY import com.ritense.document.opensearch.service.DocumentOpenSearchIndexInitializer import com.ritense.document.opensearch.service.SearchEngineToggle +import com.ritense.valtimo.contract.endpoint.EndpointDescription import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -39,6 +40,10 @@ class SearchEngineResource( private val indexInitializer: DocumentOpenSearchIndexInitializer, ) { + @EndpointDescription( + en = "Get active search engine", + nl = "Actieve zoekmachine ophalen", + ) @GetMapping fun getActive(): ResponseEntity = ResponseEntity.ok( @@ -48,6 +53,10 @@ class SearchEngineResource( ) ) + @EndpointDescription( + en = "Set active search engine", + nl = "Actieve zoekmachine instellen", + ) @PutMapping fun setActive(@RequestBody body: UpdateSearchEngineDto): ResponseEntity { if (!openSearchProperties.enabled) { diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/DocumentDefinitionResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/DocumentDefinitionResource.java index 60f343b866..34acde19a7 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/DocumentDefinitionResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/DocumentDefinitionResource.java @@ -30,6 +30,7 @@ import com.ritense.document.service.result.UndeployDocumentDefinitionResult; import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import jakarta.validation.Valid; import java.util.List; import org.springframework.data.domain.Page; @@ -49,35 +50,63 @@ @RequestMapping(value = "/api", produces = APPLICATION_JSON_UTF8_VALUE) public interface DocumentDefinitionResource { + @EndpointDescription( + en = "Retrieve a list of document definitions", + nl = "Haal een lijst met documentdefinities op" + ) @GetMapping("/v1/document-definition") ResponseEntity> getDocumentDefinitions( @PageableDefault(sort = {"id_name"}, direction = ASC) Pageable pageable ); + @EndpointDescription( + en = "Generate a JSON schema template for a document definition", + nl = "Genereer een JSON-schemasjabloon voor een documentdefinitie" + ) @PostMapping(value = "/management/v1/document-definition-template", consumes = APPLICATION_JSON_VALUE) ResponseEntity getDocumentDefinitionTemplate(@Valid @RequestBody DocumentDefinitionTemplateRequestDto requestDto) throws JsonProcessingException; + @EndpointDescription( + en = "Retrieve a list of document definitions for management", + nl = "Haal een lijst met documentdefinities op voor beheer" + ) @GetMapping("/management/v1/document-definition") ResponseEntity> getDocumentDefinitionsForManagement( @PageableDefault(sort = {"id_name"}, direction = ASC) Pageable pageable ); + @EndpointDescription( + en = "Retrieve a document definition by name for management", + nl = "Haal een documentdefinitie op naam op voor beheer" + ) @GetMapping("/management/v1/document-definition/{name}") ResponseEntity getDocumentDefinitionForManagement( @LoggableResource("documentDefinitionName") @PathVariable String name ); + @EndpointDescription( + en = "Retrieve a document definition by name", + nl = "Haal een documentdefinitie op naam op" + ) @GetMapping("/v1/document-definition/{name}") ResponseEntity getDocumentDefinition( @LoggableResource("documentDefinitionName") @PathVariable String name ); + @EndpointDescription( + en = "Retrieve the versions of a document definition", + nl = "Haal de versies van een documentdefinitie op" + ) @GetMapping("/management/v1/document-definition/{name}/version") ResponseEntity getDocumentDefinitionVersions( @LoggableResource("documentDefinitionName") @PathVariable String name ); + @EndpointDescription( + en = "Retrieve the number of unassigned documents per document definition", + nl = "Haal het aantal niet-toegewezen documenten per documentdefinitie op" + ) @GetMapping("/v1/document-definition/open/count") ResponseEntity> getUnassignedDocumentCount(); } diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentInspectionResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentInspectionResource.java index 161a8af2e6..d3c919066a 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentInspectionResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentInspectionResource.java @@ -33,6 +33,7 @@ import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; import com.ritense.valtimo.contract.audit.utils.AuditHelper; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.contract.utils.RequestHelper; import jakarta.validation.Valid; import java.time.LocalDateTime; @@ -66,6 +67,10 @@ public JsonSchemaDocumentInspectionResource( this.eventPublisher = eventPublisher; } + @EndpointDescription( + en = "Get case for inspection", + nl = "Dossier voor inspectie ophalen" + ) @GetMapping("/v1/case/{caseId}") public ResponseEntity getForInspection( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable("caseId") UUID caseId @@ -81,6 +86,10 @@ public ResponseEntity getForInspection( return ResponseEntity.ok(DocumentInspectionDto.from(document)); } + @EndpointDescription( + en = "Modify case for inspection", + nl = "Dossier voor inspectie bijwerken" + ) @PutMapping("/v1/case/{caseId}") public ResponseEntity modifyForInspection( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable("caseId") UUID caseId, diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentResource.java index 438deb4e63..131d2e28c7 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentResource.java @@ -35,6 +35,7 @@ import com.ritense.valtimo.contract.annotation.SkipComponentScan; import com.ritense.valtimo.contract.authentication.NamedUser; import com.ritense.valtimo.contract.authentication.Team; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import jakarta.validation.Valid; import java.util.List; import java.util.UUID; @@ -71,6 +72,10 @@ public JsonSchemaDocumentResource(final DocumentService documentService) { @Transactional @Override + @EndpointDescription( + en = "Get document by id", + nl = "Document ophalen" + ) @GetMapping("/v1/document/{id}") public ResponseEntity getDocument( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "id") UUID id) { @@ -83,6 +88,10 @@ public ResponseEntity getDocument( } @Override + @EndpointDescription( + en = "Create new document", + nl = "Document aanmaken" + ) @PostMapping(value = "/v1/document", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity createNewDocument( @RequestBody @Valid NewDocumentRequest request @@ -91,6 +100,10 @@ public ResponseEntity createNewDocument( } @Override + @EndpointDescription( + en = "Modify document content", + nl = "Documentinhoud bijwerken" + ) @PutMapping(value = "/v1/document", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity modifyDocumentContent( @RequestBody @Valid ModifyDocumentRequest request @@ -99,6 +112,10 @@ public ResponseEntity modifyDocumentContent( } @Override + @EndpointDescription( + en = "Delete document", + nl = "Document verwijderen" + ) @DeleteMapping(value = "/v1/document/{id}") public ResponseEntity deleteDocument( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "id") UUID id @@ -108,6 +125,10 @@ public ResponseEntity deleteDocument( } @Override + @EndpointDescription( + en = "Assign resource to document", + nl = "Bestand aan document koppelen" + ) @PostMapping(value = "/v1/document/{document-id}/resource/{resource-id}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity assignResource( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "document-id") UUID documentId, @@ -118,6 +139,10 @@ public ResponseEntity assignResource( } @Override + @EndpointDescription( + en = "Remove related file from document", + nl = "Gekoppeld bestand verwijderen" + ) @DeleteMapping(value = "/v1/document/{document-id}/resource/{resource-id}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity removeRelatedFile( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "document-id") UUID documentId, @@ -128,6 +153,10 @@ public ResponseEntity removeRelatedFile( } @Override + @EndpointDescription( + en = "Assign handler to document", + nl = "Behandelaar aan document toewijzen" + ) @PostMapping("/v1/document/{documentId}/assign") public ResponseEntity assignHandlerToDocument( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "documentId") UUID documentId, @@ -152,6 +181,10 @@ public ResponseEntity assignHandlerToDocument( } @Override + @EndpointDescription( + en = "Assign handler to documents", + nl = "Behandelaar aan documenten toewijzen" + ) @PostMapping("/v1/document/assign") public ResponseEntity assignHandlerToDocuments(@RequestBody @Valid AssignToDocumentsRequest request) { if (request.getAssigneeId() != null) { @@ -164,6 +197,10 @@ public ResponseEntity assignHandlerToDocuments(@RequestBody @Valid AssignT } @Override + @EndpointDescription( + en = "Unassign handler from document", + nl = "Behandelaar van document ontkoppelen" + ) @PostMapping("/v1/document/{documentId}/unassign") public ResponseEntity unassignHandlerFromDocument( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "documentId") UUID documentId) { @@ -179,6 +216,10 @@ public ResponseEntity unassignHandlerFromDocument( } } + @EndpointDescription( + en = "List candidate teams for document", + nl = "Kandidaat-teams voor document ophalen" + ) @GetMapping("/v1/document/{document-id}/candidate-team") @Override public ResponseEntity> getCandidateTeams( @@ -190,6 +231,10 @@ public ResponseEntity> getCandidateTeams( } @Override + @EndpointDescription( + en = "List candidate users for document", + nl = "Kandidaat-gebruikers voor document ophalen" + ) @GetMapping("/v1/document/{document-id}/candidate-user") public ResponseEntity> getCandidateUsers( @LoggableResource(resourceType = JsonSchemaDocument.class) @PathVariable(name = "document-id") UUID documentId @@ -199,6 +244,10 @@ public ResponseEntity> getCandidateUsers( } @Override + @EndpointDescription( + en = "List candidate users for documents", + nl = "Kandidaat-gebruikers voor documenten ophalen" + ) @PostMapping("/v1/document/candidate-user") public ResponseEntity> getCandidateUsersForMultipleDocuments( @RequestBody @Valid GetDocumentCandidateUsersRequest request diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSearchResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSearchResource.java index 063d851cca..cb0e49d357 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSearchResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSearchResource.java @@ -28,6 +28,7 @@ import com.ritense.document.web.rest.DocumentSearchResource; import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -50,6 +51,10 @@ public JsonSchemaDocumentSearchResource(DocumentSearchService documentSearchServ } @Override + @EndpointDescription( + en = "Search documents", + nl = "Documenten zoeken" + ) @PostMapping("/v1/document-search") public ResponseEntity> search( @RequestBody SearchRequest searchRequest, @@ -61,6 +66,10 @@ public ResponseEntity> search( } @Override + @EndpointDescription( + en = "Search documents by definition", + nl = "Documenten per definitie zoeken" + ) @PostMapping("/v1/document-definition/{name}/search") public ResponseEntity> search( @LoggableResource("documentDefinitionName") @PathVariable(name = "name") String documentDefinitionName, diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSnapshotResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSnapshotResource.java index e399864148..aaf946cc96 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSnapshotResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/JsonSchemaDocumentSnapshotResource.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import com.ritense.document.web.rest.DocumentSnapshotResource; import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.time.LocalDateTime; import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -55,6 +56,10 @@ public JsonSchemaDocumentSnapshotResource(DocumentSnapshotService documentSnapsh } @Override + @EndpointDescription( + en = "Get document snapshot by id", + nl = "Document-snapshot ophalen op id" + ) @GetMapping("/v1/document-snapshot/{id}") public ResponseEntity getDocumentSnapshot(@PathVariable(name = "id") UUID snapshotId) { return documentSnapshotService.findById(JsonSchemaDocumentSnapshotId.existingId(snapshotId)) @@ -64,6 +69,10 @@ public ResponseEntity getDocumentSnapshot(@PathVaria } @Override + @EndpointDescription( + en = "List document snapshots", + nl = "Document-snapshots ophalen" + ) @GetMapping("/v1/document-snapshot") public ResponseEntity> getDocumentSnapshots( @LoggableResource("documentDefinitionName") @RequestParam(value = "definitionName", required = false) String definitionName, diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldManagementResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldManagementResource.java index c2bf77a3e2..9e1207fa7f 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldManagementResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldManagementResource.java @@ -24,6 +24,7 @@ import com.ritense.document.web.rest.DocumentSearchFieldsManagement; import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -43,6 +44,10 @@ public SearchFieldManagementResource(final SearchFieldService searchFieldService } @Override + @EndpointDescription( + en = "List search fields for management", + nl = "Zoekvelden voor beheer ophalen" + ) @GetMapping("/v1/document-search/{documentDefinitionName}/fields") public ResponseEntity> getAdminSearchFields( @LoggableResource("documentDefinitionName") @PathVariable String documentDefinitionName) { diff --git a/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldResource.java b/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldResource.java index 691e27b45d..55d771bed8 100644 --- a/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldResource.java +++ b/backend/case/src/main/java/com/ritense/document/web/rest/impl/SearchFieldResource.java @@ -24,6 +24,7 @@ import com.ritense.document.web.rest.DocumentSearchFields; import com.ritense.logging.LoggableResource; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -48,6 +49,10 @@ public SearchFieldResource(final SearchFieldService searchFieldService) { } @Override + @EndpointDescription( + en = "Add search field", + nl = "Zoekveld aanmaken" + ) @PostMapping("/v1/document-search/{documentDefinitionName}/fields") public ResponseEntity addSearchField( @LoggableResource("documentDefinitionName") @PathVariable String documentDefinitionName, @@ -70,6 +75,10 @@ public ResponseEntity addSearchField( } @Override + @EndpointDescription( + en = "List search fields", + nl = "Zoekvelden ophalen" + ) @GetMapping("/v1/document-search/{documentDefinitionName}/fields") public ResponseEntity> getSearchFields( @LoggableResource("documentDefinitionName") @PathVariable String documentDefinitionName @@ -79,6 +88,10 @@ public ResponseEntity> getSearchFields( } @Override + @EndpointDescription( + en = "Update search fields", + nl = "Zoekvelden bijwerken" + ) @PutMapping("/v1/document-search/{documentDefinitionName}/fields") public ResponseEntity updateSearchField( @LoggableResource("documentDefinitionName") @PathVariable String documentDefinitionName, @@ -98,6 +111,10 @@ public ResponseEntity updateSearchField( } @Override + @EndpointDescription( + en = "Delete search field", + nl = "Zoekveld verwijderen" + ) @DeleteMapping("/v1/document-search/{documentDefinitionName}/fields") public ResponseEntity deleteSearchField( @LoggableResource("documentDefinitionName") @PathVariable String documentDefinitionName, diff --git a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt index 69e939b564..094860e159 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt @@ -27,9 +27,9 @@ import com.ritense.case.repository.CaseDefinitionListColumnRepository import com.ritense.case.repository.CaseTabDocumentDefinitionMapper import com.ritense.case.repository.CaseTabRepository import com.ritense.case.repository.CaseTabSpecificationFactory +import com.ritense.case.repository.HiddenTaskListColumnRepository import com.ritense.case.repository.QuickSearchRepository import com.ritense.case.repository.StartableItemRepository -import com.ritense.case.repository.HiddenTaskListColumnRepository import com.ritense.case.repository.TaskListColumnRepository import com.ritense.case.security.config.CaseHttpSecurityConfigurer import com.ritense.case.service.CaseDefinitionCheckerImpl @@ -48,9 +48,9 @@ import com.ritense.case.service.CaseTabImporter import com.ritense.case.service.CaseTabService import com.ritense.case.service.CaseTaskListExporter import com.ritense.case.service.CaseTaskListImporter +import com.ritense.case.service.ConfigurationIssueCaseDefinitionFinalizationChecker import com.ritense.case.service.StartableItemExporter import com.ritense.case.service.StartableItemImporter -import com.ritense.case.service.ConfigurationIssueCaseDefinitionFinalizationChecker import com.ritense.case.service.StartableItemManagementService import com.ritense.case.service.StartableItemProvider import com.ritense.case.service.StartableItemService @@ -67,6 +67,7 @@ import com.ritense.case_.authorization.CaseDefinitionSpecificationFactory import com.ritense.case_.repository.CaseDefinitionRepository import com.ritense.case_.repository.HiddenCaseListColumnRepository import com.ritense.case_.service.ActiveCaseDefinitionService +import com.ritense.case_.service.ExternalPluginCaseTabResolver import com.ritense.document.service.DocumentDefinitionService import com.ritense.document.service.DocumentSearchService import com.ritense.document.service.DocumentService @@ -96,6 +97,7 @@ import org.springframework.core.io.ResourceLoader import org.springframework.core.io.support.PathMatchingResourcePatternResolver import org.springframework.core.io.support.ResourcePatternResolver import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import java.util.Optional @AutoConfiguration @EnableJpaRepositories( @@ -126,7 +128,7 @@ class CaseAutoConfiguration { caseDefinitionChecker: CaseDefinitionChecker, configurationIssueRepository: CaseDefinitionConfigurationIssueRepository, caseDefinitionImportPreviewService: CaseDefinitionImportPreviewService, - pluginConfigurationMappingResolver: PluginConfigurationMappingResolver?, + pluginConfigurationMappingResolvers: List, ): CaseDefinitionResource { return CaseDefinitionResource( service, @@ -137,7 +139,7 @@ class CaseAutoConfiguration { caseDefinitionChecker, configurationIssueRepository, caseDefinitionImportPreviewService, - pluginConfigurationMappingResolver, + pluginConfigurationMappingResolvers, ) } @@ -331,9 +333,11 @@ class CaseAutoConfiguration { fun caseTabExporter( objectMapper: ObjectMapper, caseTabService: CaseTabService, + externalPluginCaseTabResolver: Optional, ) = CaseTabExporter( objectMapper, - caseTabService + caseTabService, + externalPluginCaseTabResolver ) @Bean @@ -358,8 +362,10 @@ class CaseAutoConfiguration { @ConditionalOnMissingBean(CaseTabImporter::class) fun caseTabImporter( objectMapper: ObjectMapper, - caseTabRepository: CaseTabRepository - ) = CaseTabImporter(objectMapper, caseTabRepository) + caseTabRepository: CaseTabRepository, + applicationEventPublisher: ApplicationEventPublisher, + pluginConfigurationMappingResolvers: List + ) = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, pluginConfigurationMappingResolvers) @Bean @ConditionalOnMissingBean(CaseTaskListExporter::class) diff --git a/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt b/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt index f7c4a043ed..9ed7afe495 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt @@ -16,6 +16,7 @@ package com.ritense.case.deployment +import com.fasterxml.jackson.annotation.JsonInclude import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabType @@ -25,6 +26,19 @@ data class CaseTabDto( val type: CaseTabType, val contentKey: String, val showTasks: Boolean = false, + + /** + * Design-time plugin identity for `EXTERNAL_PLUGIN` tabs only, populated by the exporter so the + * export is self-describing (the `contentKey` alone carries just the configuration id). Left + * `null`/absent for every other tab type and for exports produced before this field existed; + * the import preview falls back to resolving the configuration when these are absent. Serialized + * only when set so unaffected tab types export byte-for-byte as before. + */ + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionKey: String? = null, + + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginVersion: String? = null, ) { companion object { fun of(caseTab: CaseTab) = CaseTabDto( diff --git a/backend/case/src/main/kotlin/com/ritense/case/domain/CaseTabType.kt b/backend/case/src/main/kotlin/com/ritense/case/domain/CaseTabType.kt index 8dda2ada0e..4ed2e39c2c 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/domain/CaseTabType.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/domain/CaseTabType.kt @@ -22,7 +22,8 @@ enum class CaseTabType { STANDARD, FORMIO, CUSTOM, - WIDGETS; + WIDGETS, + EXTERNAL_PLUGIN; val value: String @JsonValue get() = name.lowercase() diff --git a/backend/case/src/main/kotlin/com/ritense/case/security/config/CaseHttpSecurityConfigurer.kt b/backend/case/src/main/kotlin/com/ritense/case/security/config/CaseHttpSecurityConfigurer.kt index 10f6ea2320..69c124c403 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/security/config/CaseHttpSecurityConfigurer.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/security/config/CaseHttpSecurityConfigurer.kt @@ -107,6 +107,7 @@ class CaseHttpSecurityConfigurer : HttpSecurityConfigurer { .requestMatchers(antMatcher(GET, "$DOCUMENT_WIDGET_TAB_URL/{tabKey}/widget/{widgetKey}")) .hasAuthority(USER) .requestMatchers(antMatcher(GET, "$DOCUMENT_WIDGET_TAB_URL/{tabKey}")).hasAuthority(USER) + .requestMatchers(antMatcher(GET, "$DOCUMENT_EXTERNAL_PLUGIN_TAB_URL/{tabKey}")).hasAuthority(USER) .requestMatchers(antMatcher(POST, MANAGEMENT_HEADER_WIDGET_URL)).hasAuthority(ADMIN) .requestMatchers(antMatcher(GET, MANAGEMENT_HEADER_WIDGET_URL)).hasAuthority(ADMIN) .requestMatchers(antMatcher(PUT, MANAGEMENT_HEADER_WIDGET_URL)).hasAuthority(ADMIN) @@ -147,6 +148,8 @@ class CaseHttpSecurityConfigurer : HttpSecurityConfigurer { private const val MANAGEMENT_WIDGET_TAB_URL = "/api/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/widget-tab" private const val DOCUMENT_WIDGET_TAB_URL = "/api/v1/document/{documentId}/widget-tab" + private const val DOCUMENT_EXTERNAL_PLUGIN_TAB_URL = + "/api/v1/document/{documentId}/external-plugin-tab" private const val MANAGEMENT_HEADER_WIDGET_URL = "/api/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/header-widget" private const val HEADER_WIDGET_URL = diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt index 4c875914fe..01adae55bd 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt @@ -83,17 +83,23 @@ class CaseDefinitionCheckerImpl( } override fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationType: String) { + assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, listOf(configurationType)) + } + + override fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationTypes: Collection) { assertCanUpdateGlobalConfiguration() val caseDefinition = caseDefinitionRepository.findById(caseDefinitionId).orElse(null) ?: error("CaseDefinition $caseDefinitionId does not exist.") if (!caseDefinition.final) { return } - val hasUnresolvedIssue = configurationIssueRepository.findUnresolvedByCaseDefinitionIdAndIssueType( - caseDefinitionId, configurationType - ) != null + val hasUnresolvedIssue = configurationTypes.any { configurationType -> + configurationIssueRepository.findUnresolvedByCaseDefinitionIdAndIssueType( + caseDefinitionId, configurationType + ) != null + } require(hasUnresolvedIssue) { - "Failed to update CaseDefinition $caseDefinitionId. This case definition is final and has no unresolved configuration issues of type '$configurationType'." + "Failed to update CaseDefinition $caseDefinitionId. This case definition is final and has no unresolved configuration issues of type(s) '${configurationTypes.joinToString()}'." } } diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt index 9cd00a4640..74c835cc4b 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt @@ -54,6 +54,8 @@ class CaseDefinitionImportPreviewService( processDefinitionKey = it.processDefinitionKey, activityId = it.activityId, existsInTargetEnvironment = it.existsInTargetEnvironment, + source = it.source, + pluginDefinitionVersion = it.pluginDefinitionVersion, ) }, ) diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt index 663632cb33..5c5b79fec5 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.ritense.case.deployment.CaseTabDto import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabType +import com.ritense.case_.service.ExternalPluginCaseTabResolver import com.ritense.exporter.ExportFile import com.ritense.exporter.ExportPrettyPrinter import com.ritense.exporter.ExportResult @@ -29,11 +30,14 @@ import com.ritense.exporter.request.ExportRequest import com.ritense.exporter.request.FormDefinitionExportRequest import com.ritense.valtimo.contract.case_.CaseDefinitionId import org.springframework.transaction.annotation.Transactional +import java.util.Optional +import java.util.UUID @Transactional(readOnly = true) class CaseTabExporter( private val objectMapper: ObjectMapper, - private val caseTabService: CaseTabService + private val caseTabService: CaseTabService, + private val externalPluginCaseTabResolver: Optional = Optional.empty(), ) : Exporter { override fun supports() = DocumentDefinitionExportRequest::class.java @@ -52,7 +56,7 @@ class CaseTabExporter( val caseTabExport = ExportFile( PATH.format(caseDefinitionKey, formattedCaseDefinitionVersion, caseDefinitionKey), - objectMapper.writer(ExportPrettyPrinter()).writeValueAsBytes(caseTabs.map(CaseTabDto::of)) + objectMapper.writer(ExportPrettyPrinter()).writeValueAsBytes(caseTabs.map(::toExportDto)) ) return ExportResult( @@ -61,6 +65,34 @@ class CaseTabExporter( ) } + /** + * Maps a tab to its export DTO, enriching `EXTERNAL_PLUGIN` tabs with their plugin definition + * (`pluginId`/version) so the export is self-describing — the import preview can then identify + * the plugin even when the referenced configuration was deleted in the target, matching how a + * process link's export already carries its plugin key/version. A tab whose configuration can no + * longer be resolved here (or when external-plugin isn't on the classpath) exports as before, + * without the plugin key. + */ + private fun toExportDto(caseTab: CaseTab): CaseTabDto { + val dto = CaseTabDto.of(caseTab) + if (caseTab.type != CaseTabType.EXTERNAL_PLUGIN) { + return dto + } + val resolver = externalPluginCaseTabResolver.orElse(null) ?: return dto + val configurationId = caseTab.contentKey.substringBefore(':').toUuidOrNull() ?: return dto + val definition = resolver.resolvePluginDefinition(configurationId) ?: return dto + return dto.copy( + pluginDefinitionKey = definition.pluginDefinitionKey, + pluginVersion = definition.pluginDefinitionVersion, + ) + } + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } + private fun createFormDefininitionExportRequests(caseTabs: List, caseDefinitionId: CaseDefinitionId): Set { return caseTabs.filter { it.type == CaseTabType.FORMIO diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt index 1fa5ee67b4..832ec5db9e 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt @@ -23,17 +23,23 @@ import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabId import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.service.event.CaseTabCreatedEvent import com.ritense.importer.ImportRequest import com.ritense.importer.Importer import com.ritense.importer.ValtimoImportTypes.Companion.CASE_TAB import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION -import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import org.springframework.context.ApplicationEventPublisher import org.springframework.transaction.annotation.Transactional +import java.util.UUID @Transactional class CaseTabImporter( private val objectMapper: ObjectMapper, private val caseTabRepository: CaseTabRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + private val pluginConfigurationMappingResolvers: List = emptyList(), ) : Importer { override fun type() = CASE_TAB @@ -42,28 +48,76 @@ class CaseTabImporter( override fun supports(fileName: String) = fileName.matches(FILENAME_REGEX) override fun import(request: ImportRequest) { - deploy(request.content.toString(Charsets.UTF_8), request.caseDefinitionId!!) + deploy(request) } - private fun deploy(fileContent: String, caseDefinitionId: CaseDefinitionId) { + /** + * A case tab is not a process link, so it gets no detection from the process-link importer. + * Trigger an in-transaction recheck here — for external-plugin tabs this is what raises the + * configuration issue when the tab references a plugin configuration missing in this environment. + */ + override fun afterImport(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId ?: return + pluginConfigurationMappingResolvers.forEach { it.recheckIssuesForCaseDefinition(caseDefinitionId) } + } + + private fun deploy(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId!! + val fileContent = request.content.toString(Charsets.UTF_8) val tabs = try { objectMapper.readValue(fileContent, object : TypeReference>() {}) } catch (e: Exception) { throw IllegalArgumentException("Failed to parse file content as valid case widget tabs: ${e.message}", e) } - val toSave = tabs.mapIndexed { index, tab -> - CaseTab( - id = CaseTabId(caseDefinitionId, tab.key), - name = tab.name, - tabOrder = index, - type = tab.type, - contentKey = tab.contentKey, - showTasks = tab.showTasks + val savedTabs = tabs.mapIndexed { index, tab -> + val contentKey = if (tab.type == CaseTabType.EXTERNAL_PLUGIN) { + remapExternalPluginContentKey(tab.contentKey, request.pluginConfigurationMappings) + } else { + tab.contentKey + } + + val saved = caseTabRepository.save( + CaseTab( + id = CaseTabId(caseDefinitionId, tab.key), + name = tab.name, + tabOrder = index, + type = tab.type, + contentKey = contentKey, + showTasks = tab.showTasks + ) ) + saved to tab } - caseTabRepository.saveAll(toSave) + // Carry the export's plugin identity onto the event so the EXTERNAL_PLUGIN side row can + // persist it — this is what keeps a tab dangling on import (its configuration missing here) + // identifiable in the repair panel afterwards. + savedTabs + .filter { (saved, _) -> saved.type == CaseTabType.EXTERNAL_PLUGIN } + .forEach { (saved, dto) -> + applicationEventPublisher.publishEvent( + CaseTabCreatedEvent(saved, dto.pluginDefinitionKey, dto.pluginVersion) + ) + } + } + + /** + * `contentKey` must stay non-blank ([CaseTab]'s invariant), so a mapping value of `null` (admin + * left the tab's configuration unmapped) leaves the original, now-dangling id in place rather + * than producing an empty/invalid content key. + */ + private fun remapExternalPluginContentKey(contentKey: String, mappings: Map?): String { + if (mappings.isNullOrEmpty()) { + return contentKey + } + + val (originalConfigId, bundleKey) = CaseExternalPluginTab.parseContentKeyOrNull(contentKey) + ?: return contentKey + + val mappedConfigId = mappings[originalConfigId] ?: return contentKey + + return CaseExternalPluginTab.formatContentKey(mappedConfigId, bundleKey) } private companion object { diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabService.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabService.kt index 8236abb5c4..27ead7d799 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabService.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabService.kt @@ -23,15 +23,19 @@ import com.ritense.authorization.request.AuthorizationResourceContext import com.ritense.authorization.request.EntityAuthorizationRequest import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository import com.ritense.case.repository.CaseTabSpecificationHelper.Companion.TAB_ORDER import com.ritense.case.repository.CaseTabSpecificationHelper.Companion.byCaseDefinitionId import com.ritense.case.repository.CaseTabSpecificationHelper.Companion.byCaseDefinitionIdAndTabKey +import com.ritense.case.service.exception.InvalidTabContentKeyException import com.ritense.case.service.exception.TabAlreadyExistsException import com.ritense.case.web.rest.dto.CaseTabDto import com.ritense.case.web.rest.dto.CaseTabUpdateDto import com.ritense.case.web.rest.dto.CaseTabUpdateOrderDto +import com.ritense.case_.domain.tab.CaseExternalPluginTab import com.ritense.case_.service.event.CaseTabCreatedEvent +import com.ritense.case_.service.event.CaseTabUpdatedEvent import com.ritense.document.domain.impl.JsonSchemaDocument import com.ritense.document.domain.impl.JsonSchemaDocumentId import com.ritense.document.service.DocumentDefinitionService @@ -133,6 +137,8 @@ class CaseTabService( throw TabAlreadyExistsException(caseTabDto.key) } + validateContentKey(caseTabDto.key, caseTabDto.type, caseTabDto.contentKey) + val caseTab = CaseTab( CaseTabId(caseDefinitionId, caseTabDto.key), caseTabDto.name, @@ -155,9 +161,11 @@ class CaseTabService( denyAuthorization() caseDefinitionChecker.assertCanUpdateCaseDefinition(caseDefinitionId) + validateContentKey(tabKey, caseTab.type, caseTab.contentKey) + val existingTab = caseTabRepository.findOne(byCaseDefinitionIdAndTabKey(caseDefinitionId, tabKey)).get() - caseTabRepository.save( + val savedTab = caseTabRepository.save( existingTab.copy( name = caseTab.name, type = caseTab.type, @@ -165,6 +173,8 @@ class CaseTabService( showTasks = caseTab.showTasks ) ) + + applicationEventPublisher.publishEvent(CaseTabUpdatedEvent(savedTab)) } fun updateCaseTabs(caseDefinitionId: CaseDefinitionId, caseTabDtos: List): List { @@ -179,6 +189,7 @@ class CaseTabService( val updatedTabs = caseTabDtos.mapIndexed { index, caseTabDto -> val existingTab = existingTabs.find { it.id.key == caseTabDto.key } ?: throw IllegalStateException("Failed to update tabs. Reason: tab with key '${caseTabDto.key}' doesn't exist.") + validateContentKey(caseTabDto.key, caseTabDto.type, caseTabDto.contentKey) existingTab.copy( name = caseTabDto.name, tabOrder = index, @@ -188,7 +199,9 @@ class CaseTabService( ) } - return caseTabRepository.saveAll(updatedTabs) + val savedTabs = caseTabRepository.saveAll(updatedTabs) + savedTabs.forEach { applicationEventPublisher.publishEvent(CaseTabUpdatedEvent(it)) } + return savedTabs } fun deleteCaseTab(caseDefinitionId: CaseDefinitionId, tabKey: String) { @@ -216,6 +229,15 @@ class CaseTabService( caseTabRepository.saveAll(caseTabs) } + private fun validateContentKey(tabKey: String, type: CaseTabType, contentKey: String) { + if (type == CaseTabType.EXTERNAL_PLUGIN && !CaseExternalPluginTab.isValidContentKey(contentKey)) { + throw InvalidTabContentKeyException( + tabKey, + "contentKey of an ${CaseTabType.EXTERNAL_PLUGIN} tab must match '[:]'" + ) + } + } + private fun denyAuthorization() { authorizationService.requirePermission( EntityAuthorizationRequest( diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/exception/InvalidTabContentKeyException.kt b/backend/case/src/main/kotlin/com/ritense/case/service/exception/InvalidTabContentKeyException.kt new file mode 100644 index 0000000000..bf68afa7a2 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case/service/exception/InvalidTabContentKeyException.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case.service.exception + +class InvalidTabContentKeyException(tabKey: String, reason: String) : + RuntimeException("Invalid contentKey for tab with key $tabKey: $reason") diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt index 9df5f33093..dd10964040 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt @@ -48,6 +48,7 @@ import com.ritense.valtimo.contract.authorization.UserManagementServiceHolder import com.ritense.valtimo.contract.case_.CaseDefinitionChecker import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import io.github.oshai.kotlinlogging.KotlinLogging @@ -88,10 +89,14 @@ class CaseDefinitionResource( private val caseDefinitionChecker: CaseDefinitionChecker, private val configurationIssueRepository: CaseDefinitionConfigurationIssueRepository, private val caseDefinitionImportPreviewService: CaseDefinitionImportPreviewService, - private val pluginConfigurationMappingResolver: PluginConfigurationMappingResolver?, + private val pluginConfigurationMappingResolvers: List, ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get case definition", + nl = "Dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}") fun getCaseDefinition( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -111,6 +116,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create case definition draft", + nl = "Concept dossierdefinitie aanmaken", + ) @PostMapping("/management/v1/case-definition/draft") fun createCaseDefinitionDraft( @Valid @RequestBody request: CaseDefinitionDraftCreateRequest @@ -123,6 +132,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete case definition", + nl = "Dossierdefinitie verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}") fun deleteCaseDefinition( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -133,6 +146,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update case definition", + nl = "Dossierdefinitie bijwerken", + ) @PatchMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}") fun updateCaseDefinition( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -147,6 +164,10 @@ class CaseDefinitionResource( return ResponseEntity.ok(CaseDefinitionResponseDto.of(caseDefinition)) } + @EndpointDescription( + en = "List case definitions", + nl = "Dossierdefinities ophalen", + ) @GetMapping("/v1/case-definition") fun getCaseDefinitions( @RequestParam caseDefinitionKey: String?, @@ -162,6 +183,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List case definitions (management)", + nl = "Dossierdefinities ophalen (beheer)", + ) @GetMapping("/management/v1/case-definition") fun getCaseDefinitionsForManagement( @RequestParam caseDefinitionKey: String?, @@ -190,6 +215,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List case definition versions", + nl = "Versies van dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version") fun getCaseDefinitionVersions( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -201,6 +230,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Finalize case definition", + nl = "Dossierdefinitie definitief maken", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/finalize") fun finalizeCaseDefinition( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -213,6 +246,10 @@ class CaseDefinitionResource( ) } + @EndpointDescription( + en = "Get case settings", + nl = "Dossierinstellingen ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/settings") fun getCaseSettings( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -228,6 +265,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "Get case settings (management)", + nl = "Dossierinstellingen ophalen (beheer)", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/settings") @RunWithoutAuthorization fun getCaseSettingsForManagement( @@ -245,6 +286,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "Update case settings", + nl = "Dossierinstellingen bijwerken", + ) @PatchMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/settings") @RunWithoutAuthorization fun updateCaseSettingsForManagement( @@ -266,6 +311,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "Get active case definition", + nl = "Actieve dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}") @RunWithoutAuthorization fun getActive( @@ -279,6 +328,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "Set active case definition", + nl = "Actieve dossierdefinitie instellen", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/active") @RunWithoutAuthorization fun setActive( @@ -298,6 +351,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "List hidden case list columns", + nl = "Verborgen dossierlijstkolommen ophalen", + ) @GetMapping("/v1/case/{caseDefinitionName}/hidden-list-column") fun getHiddenCaseListColumnForUser( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String @@ -312,6 +369,10 @@ class CaseDefinitionResource( ) } + @EndpointDescription( + en = "Save hidden case list columns", + nl = "Verborgen dossierlijstkolommen opslaan", + ) @PostMapping("/v1/case/{caseDefinitionName}/hidden-list-column") fun setHiddenListColumnsForUser( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, @@ -322,6 +383,10 @@ class CaseDefinitionResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "List case list columns", + nl = "Dossierlijstkolommen ophalen", + ) @GetMapping("/v1/case/{caseDefinitionName}/list-column") fun getCaseListColumn( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String @@ -329,12 +394,20 @@ class CaseDefinitionResource( return ResponseEntity.ok().body(service.getListColumns(caseDefinitionName)) } + @EndpointDescription( + en = "List case list columns (management)", + nl = "Dossierlijstkolommen ophalen (beheer)", + ) @GetMapping("/management/v1/case/{caseDefinitionName}/list-column") @RunWithoutAuthorization fun getCaseListColumnForManagement( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String ): ResponseEntity> = getCaseListColumn(caseDefinitionName) + @EndpointDescription( + en = "Create case list column", + nl = "Dossierlijstkolom aanmaken", + ) @PostMapping("/management/v1/case/{caseDefinitionName}/list-column") @RunWithoutAuthorization fun createCaseListColumnForManagement( @@ -345,6 +418,10 @@ class CaseDefinitionResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Update case list columns", + nl = "Dossierlijstkolommen bijwerken", + ) @PutMapping("/management/v1/case/{caseDefinitionName}/list-column") @RunWithoutAuthorization fun updateListColumnForManagement( @@ -355,6 +432,10 @@ class CaseDefinitionResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete case list column", + nl = "Dossierlijstkolom verwijderen", + ) @DeleteMapping("/management/v1/case/{caseDefinitionName}/list-column/{columnKey}") @RunWithoutAuthorization fun deleteListColumnForManagement( @@ -365,6 +446,10 @@ class CaseDefinitionResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Export case definition", + nl = "Dossierdefinitie exporteren", + ) @GetMapping( "/management/v1/case/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/export", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE] @@ -384,6 +469,10 @@ class CaseDefinitionResource( .body(baos.toByteArray()) } + @EndpointDescription( + en = "Preview case definition import", + nl = "Voorbeeld van dossierdefinitie-import ophalen", + ) @PostMapping("/management/v1/case/import/preview") @RunWithoutAuthorization fun importPreview( @@ -398,6 +487,10 @@ class CaseDefinitionResource( } } + @EndpointDescription( + en = "Import case definition", + nl = "Dossierdefinitie importeren", + ) @PostMapping("/management/v1/case/import") @RunWithoutAuthorization fun import( @@ -427,6 +520,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List configuration issues for case definition", + nl = "Configuratieproblemen voor dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/configuration-issues") fun getConfigurationIssues( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -437,18 +534,28 @@ class CaseDefinitionResource( return ResponseEntity.ok(issues.map { CaseDefinitionConfigurationIssueDto.of(it) }) } + @EndpointDescription( + en = "List dangling plugin configurations for case definition", + nl = "Losse pluginconfiguraties voor dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/dangling-plugin-configurations") @RunWithoutAuthorization fun getDanglingPluginConfigurations( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @LoggableResource("caseDefinitionVersionTag") @PathVariable caseDefinitionVersionTag: String, ): ResponseEntity> { - val resolver = pluginConfigurationMappingResolver - ?: return ResponseEntity.ok(emptyList()) + if (pluginConfigurationMappingResolvers.isEmpty()) { + return ResponseEntity.ok(emptyList()) + } val caseDefinitionId = CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag) - return ResponseEntity.ok(resolver.getDanglingPluginConfigurations(caseDefinitionId)) + val dangling = pluginConfigurationMappingResolvers.flatMap { it.getDanglingPluginConfigurations(caseDefinitionId) } + return ResponseEntity.ok(dangling) } + @EndpointDescription( + en = "Resolve plugin configuration mappings for case definition", + nl = "Pluginconfiguratiekoppelingen voor dossierdefinitie toewijzen", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/plugin-configuration-mappings") @RunWithoutAuthorization fun resolvePluginConfigurationMappings( @@ -456,14 +563,19 @@ class CaseDefinitionResource( @LoggableResource("caseDefinitionVersionTag") @PathVariable caseDefinitionVersionTag: String, @RequestBody mappings: Map, ): ResponseEntity { - val resolver = pluginConfigurationMappingResolver - ?: return ResponseEntity.status(501).build() + if (pluginConfigurationMappingResolvers.isEmpty()) { + return ResponseEntity.status(501).build() + } val caseDefinitionId = CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag) - resolver.resolve(caseDefinitionId, mappings) + pluginConfigurationMappingResolvers.forEach { it.resolve(caseDefinitionId, mappings) } return ResponseEntity.noContent().build() } @RunWithoutAuthorization + @EndpointDescription( + en = "Check case definition capabilities", + nl = "Mogelijkheden van dossierdefinitie controleren", + ) @GetMapping("/management/v1/case-definition/check") fun checkCaseDefinition(): ResponseEntity { return ResponseEntity.ok( @@ -474,6 +586,10 @@ class CaseDefinitionResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Check if case definition is finalizable", + nl = "Controleren of dossierdefinitie afrondbaar is", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/finalizable") fun checkIfCaseDefinitionIsFinalizable( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseInstanceResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseInstanceResource.kt index 03f4392946..af75b4faa1 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseInstanceResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseInstanceResource.kt @@ -26,6 +26,7 @@ import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.authorization.UserManagementServiceHolder import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE import com.ritense.valtimo.contract.domain.ValtimoMediaType.TEXT_CSV_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -46,6 +47,10 @@ class CaseInstanceResource( private val exporter: CaseExporter ) { + @EndpointDescription( + en = "Search cases", + nl = "Dossiers zoeken", + ) @PostMapping("/v1/case/{caseDefinitionName}/search") fun search( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -56,6 +61,10 @@ class CaseInstanceResource( return ResponseEntity.ok(result) } + @EndpointDescription( + en = "Save quick search", + nl = "Snelzoekopdracht opslaan", + ) @PostMapping("/v1/case/{caseDefinitionKey}/stored-quick-search") fun saveQuickSearch( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -66,6 +75,10 @@ class CaseInstanceResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete quick search", + nl = "Snelzoekopdracht verwijderen", + ) @DeleteMapping("/v1/case/{caseDefinitionKey}/stored-quick-search/{title}") fun deleteQuickSearch( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -76,6 +89,10 @@ class CaseInstanceResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "List quick searches", + nl = "Snelzoekopdrachten ophalen", + ) @GetMapping("/v1/case/{caseDefinitionKey}/stored-quick-search") fun getQuickSearchList( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String @@ -92,6 +109,10 @@ class CaseInstanceResource( ) } + @EndpointDescription( + en = "Export cases as CSV", + nl = "Dossiers exporteren als CSV", + ) @PostMapping( "/v1/case/{caseDefinitionName}/export", consumes = [APPLICATION_JSON_UTF8_VALUE], diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabManagementResource.kt index 11acd86b88..6678620ffa 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabManagementResource.kt @@ -18,6 +18,7 @@ package com.ritense.case.web.rest import com.ritense.authorization.annotation.RunWithoutAuthorization import com.ritense.case.service.CaseTabService +import com.ritense.case.service.exception.InvalidTabContentKeyException import com.ritense.case.service.exception.TabAlreadyExistsException import com.ritense.case.web.rest.dto.CaseTabDto import com.ritense.case.web.rest.dto.CaseTabUpdateDto @@ -28,6 +29,7 @@ import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.authentication.UserManagementService import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -48,6 +50,10 @@ class CaseTabManagementResource( private val userManagementService: UserManagementService, ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Create case tab", + nl = "Dossiertabblad aanmaken", + ) @PostMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab") fun createCaseTab( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -62,25 +68,39 @@ class CaseTabManagementResource( ResponseEntity.ok(CaseTabWithMetadataDto.of(caseTab, userManagementService)) } catch (ex: TabAlreadyExistsException) { ResponseEntity.status(HttpStatus.CONFLICT).build() + } catch (ex: InvalidTabContentKeyException) { + ResponseEntity.badRequest().build() } } @RunWithoutAuthorization + @EndpointDescription( + en = "Update case tab order", + nl = "Volgorde dossiertabbladen bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab") fun updateOrderCaseTab( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @LoggableResource("caseDefinitionVersionTag") @PathVariable caseDefinitionVersionTag: String, @Valid @RequestBody caseTabDtos: List ): ResponseEntity> { - val caseTabs = caseTabService.updateCaseTabs( - CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag), - caseTabDtos - ) - .map { CaseTabWithMetadataDto.of(it, userManagementService) } - return ResponseEntity.ok(caseTabs) + return try { + val caseTabs = caseTabService.updateCaseTabs( + CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag), + caseTabDtos + ) + .map { CaseTabWithMetadataDto.of(it, userManagementService) } + ResponseEntity.ok(caseTabs) + } catch (ex: InvalidTabContentKeyException) { + ResponseEntity.badRequest().build() + } } @RunWithoutAuthorization + @EndpointDescription( + en = "Update case tab", + nl = "Dossiertabblad bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab/{tabKey}") fun updateCaseTab( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -88,15 +108,23 @@ class CaseTabManagementResource( @PathVariable tabKey: String, @Valid @RequestBody caseTab: CaseTabUpdateDto ): ResponseEntity { - caseTabService.updateCaseTab( - CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag), - tabKey, - caseTab - ) - return ResponseEntity.noContent().build() + return try { + caseTabService.updateCaseTab( + CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag), + tabKey, + caseTab + ) + ResponseEntity.noContent().build() + } catch (ex: InvalidTabContentKeyException) { + ResponseEntity.badRequest().build() + } } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete case tab", + nl = "Dossiertabblad verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab/{tabKey}") fun deleteCaseTab( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -108,6 +136,10 @@ class CaseTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List case tabs (management)", + nl = "Dossiertabbladen ophalen (beheer)", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab") fun getCaseTabs( @LoggableResource("caseDefinitionName") @PathVariable caseDefinitionKey: String, @@ -119,6 +151,10 @@ class CaseTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get case tab", + nl = "Dossiertabblad ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab/{tabKey}") fun getCaseTab( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabResource.kt index 3652107175..a70884d3aa 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseTabResource.kt @@ -24,6 +24,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping @@ -39,6 +40,10 @@ class CaseTabResource( ) { @Deprecated("Since 12.2.0") + @EndpointDescription( + en = "List case tabs", + nl = "Dossiertabbladen ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/tab") fun getCaseTabs( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -49,6 +54,10 @@ class CaseTabResource( return ResponseEntity.ok(tabs) } + @EndpointDescription( + en = "List case tabs for document", + nl = "Dossiertabbladen voor document ophalen", + ) @GetMapping("/v1/document/{documentId}/tab") fun getCaseTabsForDocument( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable documentId: UUID diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemManagementResource.kt index 024a9f3c07..b4e2c52969 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemManagementResource.kt @@ -28,6 +28,7 @@ import com.ritense.case.web.rest.dto.UpdateStartableItemRequest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -51,6 +52,10 @@ class StartableItemManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List startable items (management)", + nl = "Startbare items ophalen (beheer)", + ) @GetMapping fun getStartableItems( @PathVariable caseDefinitionKey: String, @@ -61,6 +66,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create startable item", + nl = "Startbaar item aanmaken", + ) @PostMapping fun createStartableItem( @PathVariable caseDefinitionKey: String, @@ -77,6 +86,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update startable item by version", + nl = "Startbaar item bijwerken op versie", + ) @PutMapping("/{itemKey}/version/{versionTag}") fun updateStartableItem( @PathVariable caseDefinitionKey: String, @@ -89,6 +102,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update startable item", + nl = "Startbaar item bijwerken", + ) @PutMapping("/{itemKey}") fun updateStartableItemWithoutVersionTag( @PathVariable caseDefinitionKey: String, @@ -100,6 +117,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get startable item properties by version", + nl = "Eigenschappen startbaar item ophalen op versie", + ) @GetMapping("/{itemKey}/version/{versionTag}/properties") fun getStartableItemProperties( @PathVariable caseDefinitionKey: String, @@ -112,6 +133,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get startable item properties", + nl = "Eigenschappen startbaar item ophalen", + ) @GetMapping("/{itemKey}/properties") fun getStartableItemPropertiesWithoutVersionTag( @PathVariable caseDefinitionKey: String, @@ -123,6 +148,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete startable item by version", + nl = "Startbaar item verwijderen op versie", + ) @DeleteMapping("/{itemKey}/version/{versionTag}") fun deleteStartableItem( @PathVariable caseDefinitionKey: String, @@ -134,6 +163,10 @@ class StartableItemManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete startable item", + nl = "Startbaar item verwijderen", + ) @DeleteMapping("/{itemKey}") fun deleteStartableItemWithoutVersionTag( @PathVariable caseDefinitionKey: String, @@ -189,6 +222,10 @@ class StartableItemManagementResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Update startable item order", + nl = "Volgorde startbare items bijwerken", + ) @PutMapping("/order") fun updateOrder( @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemResource.kt index 799e4ecb46..5175de4212 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/StartableItemResource.kt @@ -20,6 +20,7 @@ import com.ritense.case.service.StartableItemService import com.ritense.case.web.rest.dto.StartableItemDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import java.util.UUID import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -34,6 +35,10 @@ class StartableItemResource( private val startableItemService: StartableItemService, ) { + @EndpointDescription( + en = "List startable items", + nl = "Startbare items ophalen", + ) @GetMapping("/startable-item") fun getStartableItems( @RequestParam(required = false) caseDocumentId: UUID?, diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/TaskListResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/TaskListResource.kt index 1c62055675..ff1dbdd3c7 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/TaskListResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/TaskListResource.kt @@ -24,6 +24,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.authorization.UserManagementServiceHolder import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -43,6 +44,10 @@ class TaskListResource( private val service: TaskColumnService ) { + @EndpointDescription( + en = "List task list columns", + nl = "Takenlijstkolommen ophalen", + ) @GetMapping("/v1/case/{caseDefinitionName}/task-list-column") @RunWithoutAuthorization fun getTaskListColumn( @@ -51,12 +56,20 @@ class TaskListResource( return ResponseEntity.ok().body(service.getListColumns(caseDefinitionName)) } + @EndpointDescription( + en = "List task list columns (management)", + nl = "Takenlijstkolommen ophalen (beheer)", + ) @GetMapping("/management/v1/case/{caseDefinitionName}/task-list-column") @RunWithoutAuthorization fun getTaskListColumnForManagement( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String ): ResponseEntity> = getTaskListColumn(caseDefinitionName) + @EndpointDescription( + en = "Save task list column", + nl = "Takenlijstkolom opslaan", + ) @PutMapping("/management/v1/case/{caseDefinitionName}/task-list-column/{columnKey}") @RunWithoutAuthorization fun createListColumnForManagement( @@ -68,6 +81,10 @@ class TaskListResource( } @Deprecated("Since 13.0.0") + @EndpointDescription( + en = "Swap task list column order", + nl = "Volgorde takenlijstkolommen wisselen", + ) @PostMapping("/management/v1/case/{caseDefinitionName}/task-list-column") @RunWithoutAuthorization fun swapColumnOrderForManagement( @@ -78,6 +95,10 @@ class TaskListResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Reorder task list columns", + nl = "Takenlijstkolommen herordenen", + ) @PostMapping("/management/v2/case/{caseDefinitionName}/task-list-column") @RunWithoutAuthorization fun reorderColumnsForManagement( @@ -88,6 +109,10 @@ class TaskListResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete task list column", + nl = "Takenlijstkolom verwijderen", + ) @DeleteMapping("/management/v1/case/{caseDefinitionName}/task-list-column/{columnKey}") @RunWithoutAuthorization fun deleteListColumnForManagement( @@ -98,6 +123,10 @@ class TaskListResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "List hidden task list columns", + nl = "Verborgen takenlijstkolommen ophalen", + ) @GetMapping("/v1/case/{caseDefinitionName}/hidden-task-list-column") fun getHiddenTaskListColumns( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String @@ -106,6 +135,10 @@ class TaskListResource( return ResponseEntity.ok().body(service.getHiddenTaskListColumns(caseDefinitionName, currentUserId)) } + @EndpointDescription( + en = "Save hidden task list columns", + nl = "Verborgen takenlijstkolommen opslaan", + ) @PostMapping("/v1/case/{caseDefinitionName}/hidden-task-list-column") fun saveHiddenTaskListColumns( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt index 1e1fc1c05c..33ddb12378 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt @@ -25,4 +25,6 @@ data class PluginConfigurationPreviewDto( val processDefinitionKey: String, val activityId: String, val existsInTargetEnvironment: Boolean, + val source: String = "embedded", + val pluginDefinitionVersion: String? = null, ) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt b/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt index 986cd990fe..4418de0efe 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -24,21 +24,29 @@ import com.ritense.case_.domain.tab.CaseWidgetTabWidget import com.ritense.case_.listener.CaseHeaderWidgetCaseEventListener import com.ritense.case_.listener.CaseTabCaseEventListener import com.ritense.case_.listener.CaseTagCaseEventListener +import com.ritense.case_.repository.CaseExternalPluginTabRepository import com.ritense.case_.repository.CaseHeaderWidgetRepository import com.ritense.case_.repository.CaseWidgetTabRepository import com.ritense.case_.repository.CaseWidgetTabWidgetSpecificationFactory +import com.ritense.case_.repository.ExternalPluginCaseWidgetRepository +import com.ritense.case_.rest.CaseExternalPluginTabResource import com.ritense.case_.rest.CaseHeaderWidgetManagementResource import com.ritense.case_.rest.CaseHeaderWidgetResource import com.ritense.case_.rest.CaseWidgetTabManagementResource import com.ritense.case_.rest.CaseWidgetTabResource +import com.ritense.case_.rest.MetrolineManagementResource import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto import com.ritense.case_.service.ActiveCaseDefinitionService +import com.ritense.case_.service.CaseExternalPluginTabService +import com.ritense.case_.service.CaseExternalPluginWidgetService import com.ritense.case_.service.CaseHeaderWidgetExporter import com.ritense.case_.service.CaseHeaderWidgetImporter import com.ritense.case_.service.CaseHeaderWidgetService import com.ritense.case_.service.CaseWidgetService import com.ritense.case_.service.CaseWidgetTabExporter import com.ritense.case_.service.CaseWidgetTabImporter +import com.ritense.case_.service.ExternalPluginCaseTabResolver +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver import com.ritense.case_.widget.CaseWidgetAnnotatedClassResolver import com.ritense.case_.widget.CaseWidgetDataProvider import com.ritense.case_.widget.CaseWidgetJacksonModule @@ -48,21 +56,22 @@ import com.ritense.case_.widget.collection.CollectionCaseWidgetMapper import com.ritense.case_.widget.custom.CustomCaseWidgetDataProvider import com.ritense.case_.widget.custom.CustomCaseWidgetMapper import com.ritense.case_.widget.divider.DividerCaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDataProvider +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetMapper import com.ritense.case_.widget.fields.FieldsCaseWidgetDataProvider import com.ritense.case_.widget.fields.FieldsCaseWidgetMapper +import com.ritense.case_.widget.fieldsheader.FieldsCaseHeaderWidgetDataProvider import com.ritense.case_.widget.highlight.HighlightCaseWidgetDataProvider import com.ritense.case_.widget.highlight.HighlightCaseWidgetMapper import com.ritense.case_.widget.image.ImageCaseWidgetDataProvider import com.ritense.case_.widget.image.ImageCaseWidgetMapper -import com.ritense.case_.widget.fieldsheader.FieldsCaseHeaderWidgetDataProvider import com.ritense.case_.widget.map.MapCaseWidgetDataProvider import com.ritense.case_.widget.map.MapCaseWidgetMapper -import com.ritense.case_.widget.personcard.PersonCardCaseWidgetDataProvider -import com.ritense.case_.widget.personcard.PersonCardCaseWidgetMapper -import com.ritense.case_.rest.MetrolineManagementResource import com.ritense.case_.widget.metroline.MetrolineCaseWidgetDataProvider import com.ritense.case_.widget.metroline.MetrolineCaseWidgetMapper import com.ritense.case_.widget.metroline.ZaakMetrolineDataService +import com.ritense.case_.widget.personcard.PersonCardCaseWidgetDataProvider +import com.ritense.case_.widget.personcard.PersonCardCaseWidgetMapper import com.ritense.case_.widget.table.TableCaseWidgetDataProvider import com.ritense.case_.widget.table.TableCaseWidgetMapper import com.ritense.case_.widget.text.TextCaseWidgetMapper @@ -72,6 +81,7 @@ import com.ritense.document.service.DocumentService import com.ritense.document.service.InternalCaseStatusService import com.ritense.valtimo.contract.case_.CaseDefinitionChecker import com.ritense.valtimo.contract.database.QueryDialectHelper +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import com.ritense.valueresolver.ValueResolverService import com.ritense.widget.map.geojson.GeoJsonMapper import com.ritense.widget.map.geojson.Wgs84FeatureNormalizer @@ -81,14 +91,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean -import java.util.Optional import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import java.util.Optional @AutoConfiguration @EnableJpaRepositories( basePackageClasses = [ CaseWidgetTabRepository::class, - CaseHeaderWidgetRepository::class + CaseHeaderWidgetRepository::class, + CaseExternalPluginTabRepository::class ] ) @EntityScan(basePackages = ["com.ritense.case_.domain", "com.ritense.case_.widget"]) @@ -105,7 +116,8 @@ class CaseWidgetAutoConfiguration { caseWidgetDataProviders: List, documentService: DocumentService, caseDefinitionChecker: CaseDefinitionChecker, - valueResolverService: ValueResolverService + valueResolverService: ValueResolverService, + pluginConfigurationMappingResolvers: List ) = CaseWidgetService( documentService, caseWidgetTabRepository, @@ -114,7 +126,8 @@ class CaseWidgetAutoConfiguration { caseWidgetMappers as List>, caseWidgetDataProviders as List, caseDefinitionChecker, - valueResolverService + valueResolverService, + pluginConfigurationMappingResolvers ) @ConditionalOnMissingBean(CaseWidgetTabWidgetSpecificationFactory::class) @@ -128,8 +141,9 @@ class CaseWidgetAutoConfiguration { fun caseWidgetTabExporter( objectMapper: ObjectMapper, caseTabService: CaseTabService, - caseWidgetService: CaseWidgetService - ) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService) + caseWidgetService: CaseWidgetService, + externalPluginCaseWidgetResolver: Optional, + ) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, externalPluginCaseWidgetResolver) @Bean @ConditionalOnMissingBean(CaseWidgetTabImporter::class) @@ -138,11 +152,13 @@ class CaseWidgetAutoConfiguration { validator: Validator, caseWidgetTabRepository: CaseWidgetTabRepository, caseWidgetMappers: List>, + pluginConfigurationMappingResolvers: List, ) = CaseWidgetTabImporter( objectMapper, validator, caseWidgetTabRepository, - caseWidgetMappers as List> + caseWidgetMappers as List>, + pluginConfigurationMappingResolvers, ) @Bean @@ -170,6 +186,40 @@ class CaseWidgetAutoConfiguration { caseWidgetService: CaseWidgetService ) = CaseWidgetTabResource(caseWidgetService) + @ConditionalOnMissingBean(CaseExternalPluginTabService::class) + @Bean + fun caseExternalPluginTabService( + documentService: DocumentService, + caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + caseTabRepository: CaseTabRepository, + authorizationService: AuthorizationService, + externalPluginCaseTabResolver: Optional, + ) = CaseExternalPluginTabService( + documentService, + caseExternalPluginTabRepository, + caseTabRepository, + authorizationService, + externalPluginCaseTabResolver, + ) + + @ConditionalOnMissingBean(CaseExternalPluginTabResource::class) + @Bean + fun caseExternalPluginTabResource( + caseExternalPluginTabService: CaseExternalPluginTabService + ) = CaseExternalPluginTabResource(caseExternalPluginTabService) + + @ConditionalOnMissingBean(CaseExternalPluginWidgetService::class) + @Bean + fun caseExternalPluginWidgetService( + externalPluginCaseWidgetRepository: ExternalPluginCaseWidgetRepository, + caseWidgetTabRepository: CaseWidgetTabRepository, + caseTabRepository: CaseTabRepository, + ) = CaseExternalPluginWidgetService( + externalPluginCaseWidgetRepository, + caseWidgetTabRepository, + caseTabRepository, + ) + @ConditionalOnMissingBean(CaseWidgetTabManagementResource::class) @Bean fun caseWidgetTabManagementResource( @@ -231,6 +281,16 @@ class CaseWidgetAutoConfiguration { valueResolverService: ValueResolverService, ) = CustomCaseWidgetDataProvider(valueResolverService) + @ConditionalOnMissingBean(ExternalPluginCaseWidgetMapper::class) + @Bean + fun externalPluginCaseWidgetMapper() = ExternalPluginCaseWidgetMapper() + + @ConditionalOnMissingBean(ExternalPluginCaseWidgetDataProvider::class) + @Bean + fun externalPluginCaseWidgetDataProvider( + externalPluginCaseWidgetResolver: Optional, + ) = ExternalPluginCaseWidgetDataProvider(externalPluginCaseWidgetResolver) + @ConditionalOnMissingBean(HighlightCaseWidgetMapper::class) @Bean fun highlightCaseWidgetMapper() = HighlightCaseWidgetMapper() diff --git a/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt b/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt new file mode 100644 index 0000000000..cdad354e84 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.domain.tab + +import com.ritense.case.domain.CaseTabId +import jakarta.persistence.Column +import jakarta.persistence.EmbeddedId +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.util.UUID + +/** + * Side row for a case tab of type `EXTERNAL_PLUGIN`. Holds which external-plugin configuration backs + * the tab and (optionally) which `case-tab` bundle to render when the plugin ships more than one. + * Shares the `case_tab` composite key and is removed `ON DELETE CASCADE` when the parent tab is + * deleted (mirrors [CaseWidgetTab]). + * + * [pluginDefinitionKey]/[pluginDefinitionVersion] are design-time plugin identity — the same + * metadata `process_link` persists for a plugin process link. They let the "missing plugin + * configurations" repair panel identify the plugin even when [externalPluginConfigurationId] no + * longer resolves in this environment (the panel reads the database, not the export). `null` for + * rows where the plugin could not be resolved/carried at creation. + */ +@Entity +@Table(name = "case_external_plugin_tab") +data class CaseExternalPluginTab( + @EmbeddedId + val id: CaseTabId, + + @Column(name = "external_plugin_configuration_id", nullable = false) + val externalPluginConfigurationId: UUID, + + @Column(name = "bundle_key") + val bundleKey: String? = null, + + @Column(name = "plugin_definition_key") + val pluginDefinitionKey: String? = null, + + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, +) { + + companion object { + + /** + * Parses the generic `contentKey` of an `EXTERNAL_PLUGIN` tab, formatted as + * `"[:]"` (see [formatContentKey]). Returns `null` when the + * configuration-id part is not a valid UUID. + */ + fun parseContentKeyOrNull(contentKey: String): Pair? { + val configPart = contentKey.substringBefore(':') + val bundlePart = contentKey.substringAfter(':', "").takeIf { it.isNotBlank() } + val configurationId = try { + UUID.fromString(configPart) + } catch (_: IllegalArgumentException) { + return null + } + return configurationId to bundlePart + } + + /** Formats the `contentKey` counterpart of [parseContentKeyOrNull]. */ + fun formatContentKey(configurationId: UUID, bundleKey: String?): String = + if (bundleKey.isNullOrBlank()) configurationId.toString() else "$configurationId:$bundleKey" + + fun isValidContentKey(contentKey: String): Boolean = parseContentKeyOrNull(contentKey) != null + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/repository/CaseExternalPluginTabRepository.kt b/backend/case/src/main/kotlin/com/ritense/case_/repository/CaseExternalPluginTabRepository.kt new file mode 100644 index 0000000000..88a2d75687 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/repository/CaseExternalPluginTabRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.repository + +import com.ritense.case.domain.CaseTabId +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface CaseExternalPluginTabRepository : JpaRepository { + + fun findAllByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): List + + fun existsByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): Boolean +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt b/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt new file mode 100644 index 0000000000..d431b78d2a --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.repository + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +/** + * Subtype-typed repository over the single-table-inheritance widget table. Hibernate adds the + * `case_widget_type = 'external-plugin'` discriminator filter automatically, so these queries only + * ever return external-plugin widgets. Backs the delete guard and dangling-repair panel, which need + * to find/rewrite widgets by their (queryable) configuration id. + */ +interface ExternalPluginCaseWidgetRepository : + JpaRepository { + + fun findAllByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): List + + fun existsByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): Boolean +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseExternalPluginTabResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseExternalPluginTabResource.kt new file mode 100644 index 0000000000..818ebb105e --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseExternalPluginTabResource.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.rest + +import com.ritense.case_.rest.dto.ExternalPluginTabContentDto +import com.ritense.case_.service.CaseExternalPluginTabService +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import java.util.UUID + +@Controller +@SkipComponentScan +@RequestMapping("/api", produces = [APPLICATION_JSON_UTF8_VALUE]) +class CaseExternalPluginTabResource( + private val caseExternalPluginTabService: CaseExternalPluginTabService, +) { + + @EndpointDescription( + en = "Get the content descriptor for an external plugin case tab", + nl = "Inhoudsdescriptor voor een externe-plugin-zaaktab ophalen", + ) + @GetMapping("/v1/document/{documentId}/external-plugin-tab/{tabKey}") + fun getExternalPluginTab( + @PathVariable documentId: UUID, + @PathVariable tabKey: String, + ): ResponseEntity { + val content = caseExternalPluginTabService.getExternalPluginTab(documentId, tabKey) + return ResponseEntity.ofNullable(content) + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetManagementResource.kt index cab86fa5ad..cb77c0ae92 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetManagementResource.kt @@ -24,6 +24,7 @@ import com.ritense.case_.service.CaseHeaderWidgetService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -42,6 +43,10 @@ class CaseHeaderWidgetManagementResource( private val caseHeaderWidgetService: CaseHeaderWidgetService ) { + @EndpointDescription( + en = "Create case header widget", + nl = "Dossierheaderwidget aanmaken", + ) @PostMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/header-widget") fun create( @PathVariable caseDefinitionKey: String, @@ -54,6 +59,10 @@ class CaseHeaderWidgetManagementResource( return ResponseEntity.ok(created) } + @EndpointDescription( + en = "Get case header widget (management)", + nl = "Dossierheaderwidget ophalen (beheer)", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/header-widget") fun get( @PathVariable caseDefinitionKey: String, @@ -70,6 +79,10 @@ class CaseHeaderWidgetManagementResource( } } + @EndpointDescription( + en = "Update case header widget", + nl = "Dossierheaderwidget bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/header-widget") fun update( @PathVariable caseDefinitionKey: String, @@ -83,6 +96,10 @@ class CaseHeaderWidgetManagementResource( return ResponseEntity.ok(updated) } + @EndpointDescription( + en = "Delete case header widget", + nl = "Dossierheaderwidget verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/header-widget") fun delete( @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetResource.kt index 878ef16ed3..65166d1e34 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseHeaderWidgetResource.kt @@ -25,6 +25,7 @@ import com.ritense.document.service.DocumentService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity @@ -43,6 +44,10 @@ class CaseHeaderWidgetResource( private val caseWidgetService: CaseWidgetService ) { + @EndpointDescription( + en = "Get case header widget", + nl = "Dossierheaderwidget ophalen", + ) @GetMapping("/v1/case/{documentId}/header-widget") fun getCaseHeaderWidget( @PathVariable documentId: String @@ -61,6 +66,10 @@ class CaseHeaderWidgetResource( } } + @EndpointDescription( + en = "Get case header widget data", + nl = "Gegevens van dossierheaderwidget ophalen", + ) @GetMapping("/v1/case/{documentId}/header-widget/data") fun getCaseHeaderWidgetData( @PathVariable documentId: UUID, diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabManagementResource.kt index ccf043a2ba..26ac821bad 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabManagementResource.kt @@ -22,6 +22,7 @@ import com.ritense.case_.service.CaseWidgetService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -38,6 +39,10 @@ class CaseWidgetTabManagementResource( private val caseWidgetService: CaseWidgetService ) { + @EndpointDescription( + en = "Get case widget tab (management)", + nl = "Dossiertabblad met widgets ophalen (beheer)", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/widget-tab/{tabKey}") fun getCaseWidgetTab( @PathVariable caseDefinitionKey: String, @@ -51,6 +56,10 @@ class CaseWidgetTabManagementResource( return ResponseEntity.ofNullable(widgetTab) } + @EndpointDescription( + en = "Update case widget tab", + nl = "Dossiertabblad met widgets bijwerken", + ) @PostMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/widget-tab/{tabKey}") fun updateCaseWidgetTab( @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabResource.kt index f549e6fc4a..9bee96c513 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/CaseWidgetTabResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import com.ritense.case_.service.CaseWidgetService import com.ritense.document.domain.impl.JsonSchemaDocumentId.existingId import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity @@ -37,6 +38,10 @@ class CaseWidgetTabResource( private val caseWidgetService: CaseWidgetService ) { + @EndpointDescription( + en = "Get case widget tab", + nl = "Dossiertabblad met widgets ophalen", + ) @GetMapping("/v1/document/{documentId}/widget-tab/{tabKey}") fun getCaseWidgetTab( @PathVariable documentId: String, @@ -46,6 +51,10 @@ class CaseWidgetTabResource( return ResponseEntity.ofNullable(widgetTab) } + @EndpointDescription( + en = "Get case widget data", + nl = "Dossierwidgetgegevens ophalen", + ) @GetMapping("/v1/document/{documentId}/widget-tab/{tabKey}/widget/{widgetKey}") fun getCaseWidgetData( @PathVariable documentId: UUID, diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/MetrolineManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/MetrolineManagementResource.kt index 54803a334b..415ebbfbee 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/rest/MetrolineManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/MetrolineManagementResource.kt @@ -20,6 +20,7 @@ import com.ritense.case_.widget.metroline.MetrolineMode import com.ritense.case_.widget.metroline.ZaakMetrolineDataService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -32,6 +33,10 @@ class MetrolineManagementResource( private val zaakMetrolineDataService: ZaakMetrolineDataService?, ) { + @EndpointDescription( + en = "List available timeline modes", + nl = "Beschikbare tijdlijnmodi ophalen", + ) @GetMapping("/v1/metroline/available-modes") fun getAvailableModes(): ResponseEntity> { val modes = mutableListOf(MetrolineMode.INTERNAL_CASE_STATUS) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginTabContentDto.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginTabContentDto.kt new file mode 100644 index 0000000000..b83b766d8c --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginTabContentDto.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.rest.dto + +import java.util.UUID + +/** + * Content payload for an `EXTERNAL_PLUGIN` case tab. The frontend wrapper uses [bundleUrl] to render + * the plugin iframe and [context] to seed the bundle (document + case-definition coordinates). The + * iframe never receives a token — the wrapper mints a downscoped user token separately. + */ +data class ExternalPluginTabContentDto( + val bundleUrl: String?, + val configurationId: UUID, + val bundleKey: String?, + val context: ExternalPluginTabContext, +) + +data class ExternalPluginTabContext( + val documentId: String, + val caseDefinitionKey: String, + val caseDefinitionVersionTag: String, + val pluginConfigurationId: String, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt new file mode 100644 index 0000000000..83538adb09 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.rest.dto + +import java.util.UUID + +/** + * Descriptor for an `external-plugin` case widget, returned from the existing widget-data endpoint + * (`GET .../widget-tab/{tabKey}/widget/{widgetKey}`). The frontend wrapper uses [bundleUrl] to + * render the plugin iframe and [context] to seed the bundle. The iframe never receives a token — the + * wrapper mints a downscoped user token separately. Mirrors [ExternalPluginTabContentDto]. + */ +data class ExternalPluginWidgetContentDto( + val bundleUrl: String?, + val configurationId: UUID?, + val bundleKey: String?, + val context: ExternalPluginWidgetContext, +) + +data class ExternalPluginWidgetContext( + val documentId: String, + val caseDefinitionKey: String, + val caseDefinitionVersionTag: String, + val pluginConfigurationId: String?, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt new file mode 100644 index 0000000000..67a8d6fcee --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.authorization.AuthorizationService +import com.ritense.authorization.request.AuthorizationResourceContext +import com.ritense.authorization.request.EntityAuthorizationRequest +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case.service.CaseTabActionProvider.Companion.VIEW +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.rest.dto.ExternalPluginTabContentDto +import com.ritense.case_.rest.dto.ExternalPluginTabContext +import com.ritense.case_.service.event.CaseTabCreatedEvent +import com.ritense.case_.service.event.CaseTabUpdatedEvent +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import com.ritense.document.service.DocumentService +import com.ritense.document.service.findByOrNull +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.event.EventListener +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.Optional +import java.util.UUID + +/** + * Lifecycle + content service for `EXTERNAL_PLUGIN` case tabs. Mirrors [CaseWidgetService] for the + * WIDGETS type: it creates the side row on tab creation and serves PBAC-checked content for the + * detail view. The actual bundle URL is resolved through the [ExternalPluginCaseTabResolver] SPI + * (Optional, so the case module runs without the external-plugin module on the classpath). + */ +@Service +@SkipComponentScan +class CaseExternalPluginTabService( + private val documentService: DocumentService, + private val caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + private val caseTabRepository: CaseTabRepository, + private val authorizationService: AuthorizationService, + private val resolver: Optional, +) { + + /** + * On creation of an `EXTERNAL_PLUGIN` tab, persists the side row. The configuration id and the + * (optional) bundle key are carried in the generic `contentKey` as `"[:]"` + * (Phase 2.7) — this keeps the generic create path untouched, exactly as WIDGETS needs no extra + * create fields. + */ + @Transactional + @EventListener(CaseTabCreatedEvent::class) + fun handleCaseTabCreatedEvent(event: CaseTabCreatedEvent) { + if (event.tab.type != CaseTabType.EXTERNAL_PLUGIN) return + upsertSideRow(event.tab, event.pluginDefinitionKey, event.pluginDefinitionVersion) + } + + /** + * On update, re-point the side row to the (possibly changed) configuration/bundle in the tab's + * `contentKey`. `save` merges by the composite id, so it covers both an unchanged and a changed + * `contentKey`. If the tab's type changed away from `EXTERNAL_PLUGIN`, drop any stale side row. + */ + @Transactional + @EventListener(CaseTabUpdatedEvent::class) + fun handleCaseTabUpdatedEvent(event: CaseTabUpdatedEvent) { + if (event.tab.type == CaseTabType.EXTERNAL_PLUGIN) { + upsertSideRow(event.tab) + } else { + caseExternalPluginTabRepository.findByIdOrNull(event.tab.id) + ?.let { caseExternalPluginTabRepository.delete(it) } + } + } + + /** + * Tolerant by design: create/update through [com.ritense.case.service.CaseTabService] validates + * the `contentKey` shape up front, but tabs can also arrive through deployment/import with + * arbitrary content. A malformed key must not abort the surrounding transaction, so it is + * logged and skipped instead. + */ + private fun upsertSideRow( + tab: CaseTab, + importedPluginDefinitionKey: String? = null, + importedPluginDefinitionVersion: String? = null, + ) { + val parsedContentKey = CaseExternalPluginTab.parseContentKeyOrNull(tab.contentKey) + if (parsedContentKey == null) { + logger.warn { + "Skipping external-plugin side row for case tab '${tab.id.key}' of case definition " + + "'${tab.id.caseDefinitionId}': contentKey does not match '[:]'" + } + return + } + val (configurationId, bundleKey) = parsedContentKey + val resolved = resolver.orElse(null)?.resolvePluginDefinition(configurationId) + caseExternalPluginTabRepository.save( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = configurationId, + bundleKey = bundleKey, + pluginDefinitionKey = resolved?.pluginDefinitionKey ?: importedPluginDefinitionKey, + pluginDefinitionVersion = resolved?.pluginDefinitionVersion ?: importedPluginDefinitionVersion, + ) + ) + } + + @Transactional + fun getExternalPluginTab(documentId: UUID, tabKey: String): ExternalPluginTabContentDto? { + val document = runWithoutAuthorization { + documentService.findByOrNull(JsonSchemaDocumentId.existingId(documentId)) + } ?: return null + val caseDefinitionId = document.definitionId().caseDefinitionId() + checkCaseTabAccess(document as JsonSchemaDocument, tabKey) + + val tab = caseExternalPluginTabRepository.findByIdOrNull(CaseTabId(caseDefinitionId, tabKey)) + ?: return null + + val bundleUrl = resolver.orElse(null) + ?.resolveBundleUrl(tab.externalPluginConfigurationId, tab.bundleKey) + + return ExternalPluginTabContentDto( + bundleUrl = bundleUrl, + configurationId = tab.externalPluginConfigurationId, + bundleKey = tab.bundleKey, + context = ExternalPluginTabContext( + documentId = documentId.toString(), + caseDefinitionKey = caseDefinitionId.key, + caseDefinitionVersionTag = caseDefinitionId.versionTag.toString(), + pluginConfigurationId = tab.externalPluginConfigurationId.toString(), + ), + ) + } + + /** + * Lists case tabs that reference a given external-plugin configuration. Used by the external-plugin + * delete guard (Phase 2.8) so a configuration backing a live tab cannot be deleted. + */ + @Transactional(readOnly = true) + fun findUsagesForConfiguration(configurationId: UUID): List = + caseExternalPluginTabRepository.findAllByExternalPluginConfigurationId(configurationId) + .map { sideRow -> + val tab = caseTabRepository.findByIdOrNull(sideRow.id) + CaseExternalPluginTabUsage( + configurationId = configurationId, + caseDefinitionKey = sideRow.id.caseDefinitionId.key, + caseDefinitionVersionTag = sideRow.id.caseDefinitionId.versionTag.toString(), + tabKey = sideRow.id.key, + tabName = tab?.name, + ) + } + + private fun checkCaseTabAccess(document: JsonSchemaDocument, tabKey: String) { + val caseDefinitionId = document.definitionId().caseDefinitionId() + caseTabRepository.findByIdOrNull(CaseTabId(caseDefinitionId, tabKey))?.let { caseTab -> + authorizationService.requirePermission( + EntityAuthorizationRequest( + CaseTab::class.java, + VIEW, + caseTab, + ).withContext( + AuthorizationResourceContext(JsonSchemaDocument::class.java, document) + ) + ) + } + } + + companion object { + private val logger = KotlinLogging.logger {} + } +} + +/** + * One case tab that references an external-plugin configuration. Mapped to a `PluginUsageDto` by the + * external-plugin delete guard. + */ +data class CaseExternalPluginTabUsage( + val configurationId: UUID, + val caseDefinitionKey: String, + val caseDefinitionVersionTag: String, + val tabKey: String, + val tabName: String?, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt new file mode 100644 index 0000000000..b5c3cfa7b2 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.ritense.case.domain.CaseTab +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.domain.tab.CaseWidgetTab +import com.ritense.case_.repository.CaseWidgetTabRepository +import com.ritense.case_.repository.ExternalPluginCaseWidgetRepository +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Query/mutation surface for `external-plugin` case widgets, consumed by the external-plugin module + * (which compile-depends on `case`). Mirrors [CaseExternalPluginTabService] for the widget surface: + * it lets the external-plugin dangling-repair resolver find/remap widgets by configuration id, and + * lets the delete guard find the widgets that reference a configuration. + * + * The case module has no plugin knowledge, so it never decides which widgets are *dangling* — it + * exposes every external-plugin widget (with its configuration id + plugin identity) and lets the + * external-plugin resolver filter on configuration existence. + */ +@Service +@SkipComponentScan +@Transactional +class CaseExternalPluginWidgetService( + private val externalPluginCaseWidgetRepository: ExternalPluginCaseWidgetRepository, + private val caseWidgetTabRepository: CaseWidgetTabRepository, + private val caseTabRepository: CaseTabRepository, +) { + + /** + * All external-plugin widgets of a case definition, each with the configuration it references + * and the design-time plugin identity carried from a self-describing import. + */ + @Transactional(readOnly = true) + fun findExternalPluginWidgets(caseDefinitionId: CaseDefinitionId): List = + widgetTabsFor(caseDefinitionId).flatMap { tab -> + tab.widgets.filterIsInstance().map { widget -> + CaseExternalPluginWidgetRef( + caseDefinitionId = caseDefinitionId, + tabKey = tab.id.key, + widgetKey = widget.id.key, + configurationId = widget.externalPluginConfigurationId, + pluginDefinitionKey = widget.pluginDefinitionKey, + pluginDefinitionVersion = widget.pluginDefinitionVersion, + ) + } + } + + /** + * Re-points external-plugin widgets of a case definition whose current configuration id is a key + * in [mappings] to the mapped target id. Idempotent for widgets already resolved. + */ + @Transactional + fun remapConfiguration(caseDefinitionId: CaseDefinitionId, mappings: Map) { + if (mappings.isEmpty()) return + widgetTabsFor(caseDefinitionId) + .flatMap { it.widgets.filterIsInstance() } + .forEach { widget -> + val currentId = widget.externalPluginConfigurationId ?: return@forEach + val mappedId = mappings[currentId] ?: return@forEach + externalPluginCaseWidgetRepository.save(widget.withExternalPluginConfigurationId(mappedId)) + } + } + + /** + * Lists external-plugin widgets that reference a given configuration, across every case + * definition. Used by the external-plugin delete guard so a configuration backing a live widget + * cannot be deleted. + */ + @Transactional(readOnly = true) + fun findUsagesForConfiguration(configurationId: UUID): List = + externalPluginCaseWidgetRepository.findAllByExternalPluginConfigurationId(configurationId) + .mapNotNull { widget -> + val tabId = widget.id.caseWidgetTab?.id ?: return@mapNotNull null + val tab: CaseTab? = caseTabRepository.findByIdOrNull(tabId) + CaseExternalPluginWidgetUsage( + configurationId = configurationId, + caseDefinitionKey = tabId.caseDefinitionId.key, + caseDefinitionVersionTag = tabId.caseDefinitionId.versionTag.toString(), + tabKey = tabId.key, + tabName = tab?.name, + widgetKey = widget.id.key, + ) + } + + private fun widgetTabsFor(caseDefinitionId: CaseDefinitionId): List = + caseWidgetTabRepository.findAll(byCaseDefinitionId(caseDefinitionId)) + + private fun byCaseDefinitionId(caseDefinitionId: CaseDefinitionId) = + Specification { root, _, cb -> + cb.equal(root.get("id").get("caseDefinitionId"), caseDefinitionId) + } +} + +/** + * One external-plugin widget of a case definition: the configuration it references (`null` when it + * imported dangling) plus the design-time plugin identity that keeps it identifiable in the repair + * panel. Consumed by the external-plugin dangling-repair resolver. + */ +data class CaseExternalPluginWidgetRef( + val caseDefinitionId: CaseDefinitionId, + val tabKey: String, + val widgetKey: String, + val configurationId: UUID?, + val pluginDefinitionKey: String?, + val pluginDefinitionVersion: String?, +) + +/** + * One external-plugin widget that references a configuration. Mapped to a `PluginUsageDto` by the + * external-plugin delete guard. + */ +data class CaseExternalPluginWidgetUsage( + val configurationId: UUID, + val caseDefinitionKey: String, + val caseDefinitionVersionTag: String, + val tabKey: String, + val tabName: String?, + val widgetKey: String, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetService.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetService.kt index e337508250..92f26b141d 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetService.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetService.kt @@ -45,6 +45,7 @@ import com.ritense.document.service.findByOrNull import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionChecker import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import com.ritense.valueresolver.ValueResolverService import jakarta.validation.Valid import org.springframework.context.event.EventListener @@ -67,7 +68,8 @@ class CaseWidgetService( private val caseWidgetMappers: List>, private val caseWidgetDataProviders: List, private val caseDefinitionChecker: CaseDefinitionChecker, - private val valueResolverService: ValueResolverService + private val valueResolverService: ValueResolverService, + private val pluginConfigurationMappingResolvers: List = emptyList() ) { @EventListener(CaseTabCreatedEvent::class) @@ -131,8 +133,13 @@ class CaseWidgetService( }, widgetLayout = tabDto.widgetLayout ) + val savedTab = caseWidgetTabRepository.save(caseWidgetTab) + // A widget saved over management REST (e.g. the JSON editor) can reference an external-plugin + // configuration that does not resolve in this environment; recheck in-transaction so the + // configuration issue surfaces immediately instead of at the next import or repair recheck. + pluginConfigurationMappingResolvers.forEach { it.recheckIssuesForCaseDefinition(caseDefinitionId) } return CaseWidgetTabDto.of( - caseWidgetTabRepository.save(caseWidgetTab), + savedTab, caseWidgetMappers, this::viewPermissionCheck ) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt index 4f6f4b9f03..0beb3948e0 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt @@ -19,18 +19,22 @@ package com.ritense.case_.service import com.fasterxml.jackson.databind.ObjectMapper import com.ritense.case.domain.CaseTabType import com.ritense.case.service.CaseTabService +import com.ritense.case_.rest.dto.CaseWidgetTabDto +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto import com.ritense.exporter.ExportFile import com.ritense.exporter.ExportPrettyPrinter import com.ritense.exporter.ExportResult import com.ritense.exporter.Exporter import com.ritense.exporter.request.DocumentDefinitionExportRequest import org.springframework.transaction.annotation.Transactional +import java.util.Optional @Transactional(readOnly = true) class CaseWidgetTabExporter( private val objectMapper: ObjectMapper, private val caseTabService: CaseTabService, - private val caseWidgetService: CaseWidgetService + private val caseWidgetService: CaseWidgetService, + private val externalPluginCaseWidgetResolver: Optional = Optional.empty(), ) : Exporter { override fun supports() = DocumentDefinitionExportRequest::class.java @@ -53,6 +57,7 @@ class CaseWidgetTabExporter( caseTabs .filter { it.type == CaseTabType.WIDGETS } .map { caseWidgetService.getWidgetTab(it.id.caseDefinitionId, it.id.key)!! } + .map(::enrichExternalPluginWidgets) ) ) @@ -61,6 +66,39 @@ class CaseWidgetTabExporter( ) } + /** + * Stamps each `external-plugin` widget with its plugin definition (`pluginId`/version) so the + * export is self-describing — the widget stores only the configuration id, so the import preview + * can then identify the plugin even when the referenced configuration was deleted in the target. + * Mirrors `CaseTabExporter.toExportDto`. Widgets whose configuration can no longer be resolved + * (or when external-plugin isn't on the classpath) export unchanged, so unaffected case + * definitions round-trip byte-for-byte. + */ + private fun enrichExternalPluginWidgets(tab: CaseWidgetTabDto): CaseWidgetTabDto { + val resolver = externalPluginCaseWidgetResolver.orElse(null) ?: return tab + if (tab.widgets.none { it is ExternalPluginCaseWidgetDto }) return tab + return tab.copy( + widgets = tab.widgets.map { widget -> + if (widget !is ExternalPluginCaseWidgetDto) { + widget + } else { + val configurationId = widget.properties.configurationId + val definition = configurationId?.let { resolver.resolvePluginDefinition(it) } + if (definition == null) { + widget + } else { + widget.copy( + properties = widget.properties.copy( + pluginDefinitionKey = definition.pluginDefinitionKey, + pluginDefinitionVersion = definition.pluginDefinitionVersion, + ) + ) + } + } + } + ) + } + companion object { private const val PATH = "config/case/%s/%s/case/widget-tab/%s.case-widget-tab.json" } diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt index 3c1c98f86c..cd0c325958 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt @@ -25,6 +25,7 @@ import com.ritense.case_.repository.CaseWidgetTabRepository import com.ritense.case_.rest.dto.CaseWidgetTabDto import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto import com.ritense.importer.ImportRequest import com.ritense.importer.Importer import com.ritense.importer.ValtimoImportTypes.Companion.CASE_TAB @@ -32,14 +33,17 @@ import com.ritense.importer.ValtimoImportTypes.Companion.CASE_WIDGET_TAB import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION import com.ritense.importer.ValtimoImportTypes.Companion.FORM import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import com.ritense.valtimo.contract.validation.check import jakarta.validation.Validator +import java.util.UUID class CaseWidgetTabImporter( private val objectMapper: ObjectMapper, private val validator: Validator, private val caseWidgetTabRepository: CaseWidgetTabRepository, private val caseWidgetMappers: List>, + private val pluginConfigurationMappingResolvers: List = emptyList(), ) : Importer { override fun type() = CASE_WIDGET_TAB @@ -48,20 +52,42 @@ class CaseWidgetTabImporter( override fun supports(fileName: String) = fileName.matches(FILENAME_REGEX) override fun import(request: ImportRequest) { - return deploy(request.content.toString(Charsets.UTF_8), request.caseDefinitionId!!) + return deploy( + request.content.toString(Charsets.UTF_8), + request.caseDefinitionId!!, + request.pluginConfigurationMappings, + ) } - fun deploy(fileContent: String, caseDefinitionId: CaseDefinitionId) { + /** + * A case widget is not a process link, so it gets no detection from the process-link importer. + * Trigger an in-transaction recheck here — for external-plugin widgets this is what raises the + * configuration issue when a widget references a plugin configuration missing in this environment + * (mirrors [CaseTabImporter.afterImport]). + */ + override fun afterImport(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId ?: return + pluginConfigurationMappingResolvers.forEach { it.recheckIssuesForCaseDefinition(caseDefinitionId) } + } + + @JvmOverloads + fun deploy( + fileContent: String, + caseDefinitionId: CaseDefinitionId, + pluginConfigurationMappings: Map? = null, + ) { val tabs = try { objectMapper.readValue(fileContent, object : TypeReference>() {}) } catch (e: Exception) { throw IllegalArgumentException("Failed to parse file content as valid case widget tabs: ${e.message}", e) } - validator.check(tabs) - tabs.forEach { it.validate(caseDefinitionId) } + val remappedTabs = tabs.map { remapExternalPluginWidgets(it, pluginConfigurationMappings) } + + validator.check(remappedTabs) + remappedTabs.forEach { it.validate(caseDefinitionId) } - val toSave = tabs.map { tab -> + val toSave = remappedTabs.map { tab -> CaseWidgetTab( CaseTabId( caseDefinitionId = caseDefinitionId, @@ -79,7 +105,39 @@ class CaseWidgetTabImporter( caseWidgetTabRepository.saveAll(toSave) } + /** + * Applies the import wizard's plugin-configuration mapping to each `external-plugin` widget. A + * non-null mapping value re-points the widget at a target configuration; a `null` mapping value + * (admin left it unmapped) or a config id absent from the map leaves the original — now dangling — + * id in place, exactly like [CaseTabImporter.remapExternalPluginContentKey]. Keeping the original + * id (rather than nulling the column) is what lets the repair panel offer a mapping from that + * source id later, and keeps dangling detection consistent with the tab surface (config id set + * but not resolvable in this environment). The widget's design-time plugin identity is carried + * separately in `plugin_definition_key`/`version` from the self-describing export. + */ + private fun remapExternalPluginWidgets( + tab: CaseWidgetTabDto, + mappings: Map?, + ): CaseWidgetTabDto { + if (mappings.isNullOrEmpty()) return tab + if (tab.widgets.none { it is ExternalPluginCaseWidgetDto }) return tab + + return tab.copy( + widgets = tab.widgets.map { widget -> + if (widget !is ExternalPluginCaseWidgetDto) { + widget + } else { + val originalConfigId = widget.properties.configurationId + val mappedConfigId = originalConfigId?.let { mappings[it] } ?: return@map widget + widget.copy( + properties = widget.properties.copy(configurationId = mappedConfigId) + ) + } + } + ) + } + private companion object { val FILENAME_REGEX = """/case/widget-tab/([^/]+)\.case-widget-tab\.json""".toRegex() } -} \ No newline at end of file +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt new file mode 100644 index 0000000000..d54bef8e15 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import java.util.UUID + +/** + * SPI implemented by the external-plugin module to resolve the absolute bundle URL for a plugin's + * `case-tab` bundle. Declared here (in `case`) so the dependency stays one-directional + * (external-plugin → case, no cycle) while the case content endpoint can still hand the frontend a + * resolved `bundleUrl`. + * + * The case module consumes this as an `Optional`/`ObjectProvider` so it builds and runs without the + * external-plugin module on the classpath. + */ +interface ExternalPluginCaseTabResolver { + + /** + * Resolves the absolute URL of the plugin configuration's `case-tab` bundle, or `null` if the + * configuration/definition/bundle cannot be found. + * + * @param configurationId the external-plugin configuration backing the tab + * @param bundleKey the bundle key when the plugin ships more than one `case-tab` bundle; `null` + * selects the sole `case-tab` bundle + */ + fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? + + /** + * Resolves the plugin definition (`pluginId` + version) backing the configuration, or `null` + * when the configuration/definition can no longer be found. Used at export time so a `case-tab` + * export is self-describing: unlike a process link (whose export already carries its plugin + * key/version), a tab's `contentKey` holds only the configuration id. Embedding the resolved + * definition lets the import preview identify the plugin even when the referenced configuration + * was deleted in the target environment (otherwise the tab is an unidentifiable, unmappable row). + */ + fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? +} + +/** + * Design-time plugin identity of an `EXTERNAL_PLUGIN` case tab, embedded in the tab export so the + * import preview can identify the plugin without resolving the (possibly-deleted) configuration. + */ +data class ExternalPluginTabDefinition( + val pluginDefinitionKey: String, + val pluginDefinitionVersion: String, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt new file mode 100644 index 0000000000..1243b12480 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import java.util.UUID + +/** + * SPI implemented by the external-plugin module to resolve the absolute bundle URL for a plugin's + * `case-widget` bundle. Declared here (in `case`) so the dependency stays one-directional + * (external-plugin → case, no cycle) while the widget-data endpoint can still hand the frontend a + * resolved `bundleUrl`. The sibling of [ExternalPluginCaseTabResolver] for the widget surface; + * reuses [ExternalPluginTabDefinition] for the design-time plugin identity. + * + * The case module consumes this as an `Optional` so it builds and runs without the external-plugin + * module on the classpath. + */ +interface ExternalPluginCaseWidgetResolver { + + /** + * Resolves the absolute URL of the plugin configuration's `case-widget` bundle, or `null` if the + * configuration/definition/bundle cannot be found. + * + * @param configurationId the external-plugin configuration backing the widget + * @param bundleKey the bundle key when the plugin ships more than one `case-widget` bundle; + * `null` selects the sole `case-widget` bundle + */ + fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? + + /** + * Resolves the plugin definition (`pluginId` + version) backing the configuration, or `null` + * when the configuration/definition can no longer be found. Used at export time so a + * `case-widget` export is self-describing: the widget stores only the configuration id, so + * embedding the resolved definition lets the import preview identify the plugin even when the + * referenced configuration was deleted in the target environment. + */ + fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt index 10f9df7a11..3fe2e3d2c3 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt @@ -18,4 +18,15 @@ package com.ritense.case_.service.event import com.ritense.case.domain.CaseTab -data class CaseTabCreatedEvent(val tab: CaseTab) \ No newline at end of file +/** + * [pluginDefinitionKey]/[pluginDefinitionVersion] carry the design-time plugin identity for an + * `EXTERNAL_PLUGIN` tab so its side row can persist it — populated by the importer from the + * self-describing export, so a tab dangling on import (its configuration missing here) stays + * identifiable. `null` for other tab types and for callers that resolve the plugin from the + * (present) configuration instead. + */ +data class CaseTabCreatedEvent( + val tab: CaseTab, + val pluginDefinitionKey: String? = null, + val pluginDefinitionVersion: String? = null, +) \ No newline at end of file diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabUpdatedEvent.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabUpdatedEvent.kt new file mode 100644 index 0000000000..6cbfcbc1b3 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabUpdatedEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service.event + +import com.ritense.case.domain.CaseTab + +/** + * Published after a single case tab is updated (name/type/contentKey/showTasks). Type-specific side + * tables (e.g. `case_external_plugin_tab`) listen for it to keep their rows in sync with the tab's + * `contentKey` — the counterpart to [CaseTabCreatedEvent]. + */ +data class CaseTabUpdatedEvent(val tab: CaseTab) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt new file mode 100644 index 0000000000..3fbd3929cb --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidget +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.valtimo.contract.annotation.AllOpen +import com.ritense.valtimo.contract.conditions.Condition +import com.ritense.widget.domain.WidgetAction +import com.ritense.widget.domain.WidgetColor +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Entity +import java.util.UUID + +/** + * `external-plugin` case-widget subtype: a card in a WIDGETS tab's grid rendered as a sandboxed + * iframe of an external plugin's `case-widget` bundle. Unlike the `custom` widget (a JSON + * `properties` column) the external-plugin config maps to dedicated, queryable columns so the delete + * guard and dangling-repair panel can find widgets by configuration id portably across the + * Postgres/MySQL dual database support. + * + * A widget that imported dangling (its configuration missing in this environment) keeps the + * original, now-unresolvable [externalPluginConfigurationId] rather than being nulled, so one repair + * mapping can re-point every widget that shared the source id; + * [pluginDefinitionKey]/[pluginDefinitionVersion] keep it identifiable in the repair panel. + */ +@AllOpen +@Entity +@DiscriminatorValue("external-plugin") +class ExternalPluginCaseWidget( + id: CaseWidgetTabWidgetId, + title: String, + icon: String? = null, + color: WidgetColor = WidgetColor.WHITE, + order: Int, + width: Int, + highContrast: Boolean, + isCompact: Boolean?, + actions: List, + displayConditions: List>, + + @Column(name = "external_plugin_configuration_id") + val externalPluginConfigurationId: UUID?, + + @Column(name = "bundle_key") + val bundleKey: String?, + + @Column(name = "plugin_definition_key") + val pluginDefinitionKey: String? = null, + + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, +) : CaseWidgetTabWidget( + id, title, icon, color, order, width, highContrast, isCompact, actions, displayConditions +) { + override fun copy(id: CaseWidgetTabWidgetId) = ExternalPluginCaseWidget( + id = id, + title = title, + icon = icon, + color = color, + order = order, + width = width, + highContrast = highContrast, + isCompact = isCompact, + actions = actions, + displayConditions = displayConditions, + externalPluginConfigurationId = externalPluginConfigurationId, + bundleKey = bundleKey, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + + /** Same widget, re-pointed at another external-plugin configuration (used by dangling repair). */ + fun withExternalPluginConfigurationId(configurationId: UUID?) = ExternalPluginCaseWidget( + id = id, + title = title, + icon = icon, + color = color, + order = order, + width = width, + highContrast = highContrast, + isCompact = isCompact, + actions = actions, + displayConditions = displayConditions, + externalPluginConfigurationId = configurationId, + bundleKey = bundleKey, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt new file mode 100644 index 0000000000..5eb82d57db --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.ritense.case_.rest.dto.ExternalPluginWidgetContentDto +import com.ritense.case_.rest.dto.ExternalPluginWidgetContext +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.case_.widget.CaseWidgetDataProvider +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.springframework.data.domain.Pageable +import java.util.Optional +import java.util.UUID + +/** + * Returns the iframe descriptor (bundle URL + context) for an `external-plugin` case widget, served + * through the existing widget-data endpoint. Unlike a first-party widget's data provider it returns + * no document business data — only what the frontend needs to render the sandboxed plugin iframe. + * + * Resolving the bundle URL lives here rather than in the mapper's `toDto` because only the data + * provider has the [documentId] needed to build the context; it also reuses the endpoint's + * per-widget PBAC check. When the resolver is absent (external-plugin not on the classpath) or the + * configuration is dangling/unresolvable, [ExternalPluginWidgetContentDto.bundleUrl] is `null` and + * the frontend shows an unavailable state — matching the case tab. + */ +class ExternalPluginCaseWidgetDataProvider( + private val resolver: Optional, +) : CaseWidgetDataProvider { + + override fun supports(widget: Any): Boolean = widget is ExternalPluginCaseWidget + + override fun getData( + documentId: UUID, + widget: Any, + pageable: Pageable, + caseDefinitionId: CaseDefinitionId + ): Any { + widget as ExternalPluginCaseWidget + val configurationId = widget.externalPluginConfigurationId + val bundleUrl = configurationId?.let { + resolver.orElse(null)?.resolveBundleUrl(it, widget.bundleKey) + } + + return ExternalPluginWidgetContentDto( + bundleUrl = bundleUrl, + configurationId = configurationId, + bundleKey = widget.bundleKey, + context = ExternalPluginWidgetContext( + documentId = documentId.toString(), + caseDefinitionKey = caseDefinitionId.key, + caseDefinitionVersionTag = caseDefinitionId.versionTag.toString(), + pluginConfigurationId = configurationId?.toString(), + ), + ) + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt new file mode 100644 index 0000000000..e412d292d5 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.conditions.Condition +import com.ritense.widget.domain.WidgetAction +import com.ritense.widget.domain.WidgetColor +import jakarta.validation.Valid + +@JsonTypeName("external-plugin") +data class ExternalPluginCaseWidgetDto( + override val key: String, + override val title: String, + override val icon: String?, + override val color: WidgetColor? = null, + override val width: Int, + override val highContrast: Boolean, + override val isCompact: Boolean?, + override val actions: List? = emptyList(), + override val displayConditions: List> = emptyList(), + @field:Valid val properties: ExternalPluginWidgetProperties, +) : CaseWidgetTabWidgetDto { + + /** + * A configured widget must reference a plugin configuration. The bundle key is intentionally + * optional — it is `null` when the plugin ships a single, key-less `case-widget` bundle (the + * resolver then picks the sole bundle). Choosing among several bundles is enforced client-side. + * A widget always carries a (possibly now-dangling) configuration id: the importer keeps the + * original id when a mapping is left unset, so this never rejects a repairable import. + */ + override fun validate(caseDefinitionId: CaseDefinitionId) { + require(properties.configurationId != null) { + "External-plugin widget '$key' must reference a plugin configuration." + } + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt new file mode 100644 index 0000000000..33f5d4b4f1 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.widget.domain.resolveWidgetColor +import java.util.Collections.emptyList + +/** + * Bridges the DTO's nested [ExternalPluginWidgetProperties] and the entity's dedicated columns. + */ +class ExternalPluginCaseWidgetMapper : + CaseWidgetMapper { + + override fun toDto(entity: ExternalPluginCaseWidget) = ExternalPluginCaseWidgetDto( + key = entity.id.key, + title = entity.title, + icon = entity.icon, + color = entity.color, + width = entity.width, + highContrast = entity.highContrast, + isCompact = entity.isCompact, + actions = entity.actions, + displayConditions = entity.displayConditions, + properties = ExternalPluginWidgetProperties( + configurationId = entity.externalPluginConfigurationId, + bundleKey = entity.bundleKey, + pluginDefinitionKey = entity.pluginDefinitionKey, + pluginDefinitionVersion = entity.pluginDefinitionVersion, + ) + ) + + override fun toEntity(dto: ExternalPluginCaseWidgetDto, index: Int) = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId(dto.key), + title = dto.title, + icon = dto.icon, + color = resolveWidgetColor(dto.color, dto.highContrast), + width = dto.width, + highContrast = dto.highContrast, + isCompact = dto.isCompact, + actions = dto.actions ?: emptyList(), + displayConditions = dto.displayConditions, + order = index, + externalPluginConfigurationId = dto.properties.configurationId, + bundleKey = dto.properties.bundleKey, + pluginDefinitionKey = dto.properties.pluginDefinitionKey, + pluginDefinitionVersion = dto.properties.pluginDefinitionVersion, + ) +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt new file mode 100644 index 0000000000..ede63a240c --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.fasterxml.jackson.annotation.JsonInclude +import java.util.UUID + +/** + * DTO-side configuration of an `external-plugin` case widget. Nested under `properties` (like the + * `custom` widget) for frontend consistency; the mapper unpacks it into the dedicated, + * queryable columns on `case_widget_tab_widget`. + * + * [configurationId] is the external-plugin configuration backing the widget. A widget that imported + * dangling — its configuration missing in this environment — keeps the original, now-unresolvable id + * rather than being nulled. [bundleKey] selects the + * `case-widget` bundle when the plugin ships more than one; `null` selects the sole bundle. + * [pluginDefinitionKey]/[pluginDefinitionVersion] are design-time plugin identity stamped on export + * so the import preview can identify the plugin without resolving the configuration. + */ +data class ExternalPluginWidgetProperties( + val configurationId: UUID?, + val bundleKey: String?, + + /** + * Only populated by the exporter (self-describing export). Omitted from JSON when absent so a + * widget that was never exported — and every other environment's data — round-trips cleanly. + */ + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionKey: String? = null, + + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionVersion: String? = null, +) diff --git a/backend/case/src/main/kotlin/com/ritense/document/web/rest/CaseTagResource.kt b/backend/case/src/main/kotlin/com/ritense/document/web/rest/CaseTagResource.kt index 3797aeaa53..18ddd4d444 100644 --- a/backend/case/src/main/kotlin/com/ritense/document/web/rest/CaseTagResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/document/web/rest/CaseTagResource.kt @@ -26,6 +26,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -46,6 +47,10 @@ class CaseTagResource( private val caseTagService: CaseTagService ) { + @EndpointDescription( + en = "List case tags by version", + nl = "Dossiertags per versie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag") fun getCaseTags( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -55,6 +60,10 @@ class CaseTagResource( return ResponseEntity.ok(caseTags.map { CaseTagResponseDto(it) }.sortedBy { it.order }) } + @EndpointDescription( + en = "List case tags", + nl = "Dossiertags ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/case-tag") fun getCaseTags( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -64,6 +73,10 @@ class CaseTagResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List case tags for management", + nl = "Dossiertags voor beheer ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag") fun getCaseTagForManagement( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -74,6 +87,10 @@ class CaseTagResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create case tag", + nl = "Dossiertag aanmaken", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag") fun createCaseTag( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -95,6 +112,10 @@ class CaseTagResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update case tags", + nl = "Dossiertags bijwerken", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag") fun editCaseTags( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -106,6 +127,10 @@ class CaseTagResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update case tag", + nl = "Dossiertag bijwerken", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag/{caseTagKey}") fun updateCaseTag( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -118,6 +143,10 @@ class CaseTagResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete case tag", + nl = "Dossiertag verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/case-tag/{caseTagKey}") fun deleteCaseTag( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentDefinitionManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentDefinitionManagementResource.kt index 1255029d7d..5eb7960ff2 100644 --- a/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentDefinitionManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentDefinitionManagementResource.kt @@ -24,6 +24,7 @@ import com.ritense.document.service.result.DeployDocumentDefinitionResult import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -41,6 +42,10 @@ class DocumentDefinitionManagementResource( private val documentDefinitionService: DocumentDefinitionService ) { + @EndpointDescription( + en = "Get document definition", + nl = "Documentdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/document-definition") fun getDocumentDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -53,6 +58,10 @@ class DocumentDefinitionManagementResource( return ResponseEntity.of(documentDefinition) } + @EndpointDescription( + en = "Deploy document definition", + nl = "Documentdefinitie uitrollen", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/document-definition") fun putDocumentDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentMigrationManagementResource.kt b/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentMigrationManagementResource.kt index 22f7ae2d92..6819b5c25f 100644 --- a/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentMigrationManagementResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/document/web/rest/DocumentMigrationManagementResource.kt @@ -22,6 +22,7 @@ import com.ritense.document.domain.DocumentMigrationRequest import com.ritense.document.service.DocumentMigrationService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -37,6 +38,10 @@ class DocumentMigrationManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get document migration conflicts", + nl = "Documentmigratieconflicten ophalen", + ) @PostMapping("/v1/document-definition/migration/conflicts") fun getConflicts( @Valid @RequestBody documentMigrationRequest: DocumentMigrationRequest, @@ -46,6 +51,10 @@ class DocumentMigrationManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Migrate documents", + nl = "Documenten migreren", + ) @PostMapping("/v1/document-definition/migrate") fun migrateDocuments( @Valid @RequestBody documentMigrationRequest: DocumentMigrationRequest, diff --git a/backend/case/src/main/kotlin/com/ritense/document/web/rest/InternalCaseStatusResource.kt b/backend/case/src/main/kotlin/com/ritense/document/web/rest/InternalCaseStatusResource.kt index b098b68de5..426c6c730f 100644 --- a/backend/case/src/main/kotlin/com/ritense/document/web/rest/InternalCaseStatusResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/document/web/rest/InternalCaseStatusResource.kt @@ -25,6 +25,7 @@ import com.ritense.document.web.rest.dto.InternalCaseStatusUpdateRequestDto import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -44,12 +45,20 @@ class InternalCaseStatusResource( private val internalCaseStatusService: InternalCaseStatusService ) { + @EndpointDescription( + en = "List all internal case statuses", + nl = "Alle interne dossierstatussen ophalen", + ) @GetMapping("/v1/internal-status") fun getAllInternalCaseStatuses(): ResponseEntity> { val internalCaseStatuses = internalCaseStatusService.getAllInternalCaseStatuses() return ResponseEntity.ok(internalCaseStatuses.map { InternalCaseStatusResponseDto(it) }) } + @EndpointDescription( + en = "List internal case statuses for case definition", + nl = "Interne dossierstatussen voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/internal-status") fun getInternalCaseStatuses( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String @@ -59,6 +68,10 @@ class InternalCaseStatusResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List internal case statuses for management", + nl = "Interne dossierstatussen voor beheer ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionName}/internal-status") fun getInternalCaseStatusesForManagement( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String @@ -68,6 +81,10 @@ class InternalCaseStatusResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create internal case status", + nl = "Interne dossierstatus aanmaken", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionName}/internal-status") fun createInternalCaseStatus( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, @@ -87,6 +104,10 @@ class InternalCaseStatusResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update internal case status order", + nl = "Volgorde interne dossierstatussen bijwerken", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionName}/internal-status") fun editInternalCaseStatuses( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, @@ -97,6 +118,10 @@ class InternalCaseStatusResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update internal case status", + nl = "Interne dossierstatus bijwerken", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionName}/internal-status/{internalStatusKey}") fun updateInternalCaseStatus( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, @@ -108,6 +133,10 @@ class InternalCaseStatusResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete internal case status", + nl = "Interne dossierstatus verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionName}/internal-status/{internalStatusKey}") fun deleteInternalCaseStatus( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String, diff --git a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt index 941a212eb4..ae167eaaa5 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -16,28 +16,43 @@ package com.ritense.case.service -import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.service.event.CaseTabCreatedEvent import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION -import com.ritense.importer.ValtimoImportTypes.Companion.FORM +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.util.UUID @ExtendWith(MockitoExtension::class) class CaseTabImporterTest( - @Mock private val objectMapper: ObjectMapper, - @Mock private val caseTabRepository: CaseTabRepository + @Mock private val caseTabRepository: CaseTabRepository, ) { + private val objectMapper = MapperSingleton.get() + private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var importer: CaseTabImporter @BeforeEach fun before() { - importer = CaseTabImporter(objectMapper, caseTabRepository) + applicationEventPublisher = mock() + importer = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher) + whenever(caseTabRepository.save(any())).thenAnswer { it.arguments[0] } } @Test @@ -61,7 +76,143 @@ class CaseTabImporterTest( assertThat(importer.supports("/case/tab/test.case-tab-json")).isFalse() } + @Test + fun `should remap the config id embedded in the contentKey of an EXTERNAL_PLUGIN tab`() { + val sourceConfigId = UUID.randomUUID() + val targetConfigId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$sourceConfigId:overview", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + pluginConfigurationMappings = mapOf(sourceConfigId to targetConfigId), + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(caseTabRepository).save(captor.capture()) + assertThat(captor.firstValue.contentKey).isEqualTo("$targetConfigId:overview") + } + + @Test + fun `should leave the contentKey unchanged when no mapping applies`() { + val sourceConfigId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$sourceConfigId", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + pluginConfigurationMappings = null, + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(caseTabRepository).save(captor.capture()) + assertThat(captor.firstValue.contentKey).isEqualTo(sourceConfigId.toString()) + } + + @Test + fun `should publish CaseTabCreatedEvent for imported EXTERNAL_PLUGIN tabs so the side row gets created`() { + val configId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(applicationEventPublisher).publishEvent(captor.capture()) + assertThat(captor.firstValue.tab.contentKey).isEqualTo(configId.toString()) + assertThat(captor.firstValue.tab.type).isEqualTo(CaseTabType.EXTERNAL_PLUGIN) + } + + @Test + fun `should not publish CaseTabCreatedEvent for non-EXTERNAL_PLUGIN tabs`() { + val content = """ + [ + { + "key": "widgets", + "name": "Widgets", + "type": "widgets", + "contentKey": "widgets-tab", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + ) + + importer.import(request) + + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport rechecks configuration issues for the case definition (in-transaction tab detection)`() { + val resolver = mock() + val importerWithResolver = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, listOf(resolver)) + + importerWithResolver.afterImport( + ImportRequest(fileName = FILENAME, content = "[]".toByteArray(Charsets.UTF_8), caseDefinitionId = CASE_DEFINITION_ID) + ) + + verify(resolver).recheckIssuesForCaseDefinition(CASE_DEFINITION_ID) + } + + @Test + fun `afterImport does nothing without a case definition id`() { + val resolver = mock() + val importerWithResolver = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, listOf(resolver)) + + importerWithResolver.afterImport(ImportRequest(fileName = FILENAME, content = "[]".toByteArray(Charsets.UTF_8))) + + verify(resolver, never()).recheckIssuesForCaseDefinition(any()) + } + private companion object { const val FILENAME = "/case/tab/my-doc-def.case-tab.json" + val CASE_DEFINITION_ID: CaseDefinitionId = CaseDefinitionId.of("my-doc-def", "1.0.0") } -} \ No newline at end of file +} diff --git a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabServiceTest.kt b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabServiceTest.kt index 741e1f03c4..7e2be1f717 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabServiceTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabServiceTest.kt @@ -23,12 +23,17 @@ import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabId import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case.service.exception.InvalidTabContentKeyException import com.ritense.case.web.rest.dto.CaseTabDto +import com.ritense.case.web.rest.dto.CaseTabUpdateDto +import com.ritense.case.web.rest.dto.CaseTabUpdateOrderDto import com.ritense.case_.service.event.CaseTabCreatedEvent +import com.ritense.case_.service.event.CaseTabUpdatedEvent import com.ritense.document.service.DocumentDefinitionService import com.ritense.document.service.DocumentService import com.ritense.valtimo.contract.authentication.UserManagementService import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -38,11 +43,14 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification import java.util.Optional +import java.util.UUID @ExtendWith(MockitoExtension::class) @@ -88,4 +96,102 @@ class CaseTabServiceTest( verify(applicationEventPublisher).publishEvent(eq(CaseTabCreatedEvent(caseTab))) } + @Test + fun `should reject creation of an EXTERNAL_PLUGIN tab with a malformed contentKey`() { + val caseDefinitionId = CaseDefinitionId.of("myCaseDefinitionName", "1.0.0") + val caseTabDto = CaseTabDto( + key = "myKey", + name = "myName", + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "not-a-uuid:overview" + ) + + val specMock = mock>() + whenever(specMock.and(any())).thenReturn(specMock) + whenever(authorizationService.getAuthorizationSpecification(any>(), anyOrNull())).thenReturn(specMock) + whenever(caseTabRepository.findAll(any>(), any())).thenReturn(emptyList()) + + assertThatThrownBy { caseTabService.createCaseTab(caseDefinitionId, caseTabDto) } + .isInstanceOf(InvalidTabContentKeyException::class.java) + + verify(caseTabRepository, never()).save(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `should create an EXTERNAL_PLUGIN tab with a valid contentKey`() { + val caseDefinitionId = CaseDefinitionId.of("myCaseDefinitionName", "1.0.0") + val caseTab = CaseTab( + CaseTabId(caseDefinitionId, "myKey"), + "myName", + 0, + CaseTabType.EXTERNAL_PLUGIN, + "${UUID.randomUUID()}:overview" + ) + + val specMock = mock>() + whenever(specMock.and(any())).thenReturn(specMock) + whenever(authorizationService.getAuthorizationSpecification(any>(), anyOrNull())).thenReturn(specMock) + whenever(caseTabRepository.findAll(any>(), any())).thenReturn(emptyList()) + whenever(caseTabRepository.save(any())).thenReturn(caseTab) + + caseTabService.createCaseTab(caseDefinitionId, CaseTabDto.of(caseTab)) + + verify(applicationEventPublisher).publishEvent(eq(CaseTabCreatedEvent(caseTab))) + } + + @Test + fun `should reject update of an EXTERNAL_PLUGIN tab with a malformed contentKey`() { + val caseDefinitionId = CaseDefinitionId.of("myCaseDefinitionName", "1.0.0") + val updateDto = CaseTabUpdateDto( + name = "myName", + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "not-a-uuid" + ) + + assertThatThrownBy { caseTabService.updateCaseTab(caseDefinitionId, "myKey", updateDto) } + .isInstanceOf(InvalidTabContentKeyException::class.java) + + verify(caseTabRepository, never()).save(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `should publish update events when updating tab order`() { + val caseDefinitionId = CaseDefinitionId.of("myCaseDefinitionName", "1.0.0") + val existingTab = CaseTab(CaseTabId(caseDefinitionId, "myKey"), "myName", 0, CaseTabType.WIDGETS, "myContentKey") + val updateDto = CaseTabUpdateOrderDto( + key = "myKey", + name = "myName", + type = CaseTabType.STANDARD, + contentKey = "myContentKey" + ) + + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(existingTab)) + whenever(caseTabRepository.saveAll(any>())).thenAnswer { it.arguments[0] } + + caseTabService.updateCaseTabs(caseDefinitionId, listOf(updateDto)) + + verify(applicationEventPublisher).publishEvent(any()) + } + + @Test + fun `should reject tab order update containing an EXTERNAL_PLUGIN tab with a malformed contentKey`() { + val caseDefinitionId = CaseDefinitionId.of("myCaseDefinitionName", "1.0.0") + val existingTab = CaseTab(CaseTabId(caseDefinitionId, "myKey"), "myName", 0, CaseTabType.WIDGETS, "myContentKey") + val updateDto = CaseTabUpdateOrderDto( + key = "myKey", + name = "myName", + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "not-a-uuid" + ) + + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(existingTab)) + + assertThatThrownBy { caseTabService.updateCaseTabs(caseDefinitionId, listOf(updateDto)) } + .isInstanceOf(InvalidTabContentKeyException::class.java) + + verify(caseTabRepository, never()).saveAll(any>()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } } \ No newline at end of file diff --git a/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt b/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt index dad7e5dbf3..252c6d040b 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt @@ -93,7 +93,7 @@ class CaseDefinitionResourceTest : BaseTest() { caseDefinitionChecker, configurationIssueRepository, caseDefinitionImportPreviewService, - null, + emptyList(), ) mapper = MapperSingleton.get() diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseExternalPluginTabServiceTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseExternalPluginTabServiceTest.kt new file mode 100644 index 0000000000..e68c0f71a4 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseExternalPluginTabServiceTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.ritense.authorization.AuthorizationService +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.service.event.CaseTabCreatedEvent +import com.ritense.case_.service.event.CaseTabUpdatedEvent +import com.ritense.document.service.DocumentService +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class CaseExternalPluginTabServiceTest( + @Mock private val documentService: DocumentService, + @Mock private val caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + @Mock private val caseTabRepository: CaseTabRepository, + @Mock private val authorizationService: AuthorizationService, +) { + private lateinit var service: CaseExternalPluginTabService + + @BeforeEach + fun before() { + service = CaseExternalPluginTabService( + documentService, + caseExternalPluginTabRepository, + caseTabRepository, + authorizationService, + Optional.empty(), + ) + } + + @Test + fun `should upsert side row on creation of an EXTERNAL_PLUGIN tab`() { + val configurationId = UUID.randomUUID() + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, "$configurationId:overview") + + service.handleCaseTabCreatedEvent(CaseTabCreatedEvent(tab)) + + val captor = argumentCaptor() + verify(caseExternalPluginTabRepository).save(captor.capture()) + assertThat(captor.firstValue.id).isEqualTo(tab.id) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(captor.firstValue.bundleKey).isEqualTo("overview") + } + + @Test + fun `should upsert side row without bundle key when contentKey only contains a configuration id`() { + val configurationId = UUID.randomUUID() + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, configurationId.toString()) + + service.handleCaseTabCreatedEvent(CaseTabCreatedEvent(tab)) + + val captor = argumentCaptor() + verify(caseExternalPluginTabRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(captor.firstValue.bundleKey).isNull() + } + + @Test + fun `should not create a side row for a non-EXTERNAL_PLUGIN tab`() { + val tab = caseTab(CaseTabType.WIDGETS, "my-widgets-tab") + + service.handleCaseTabCreatedEvent(CaseTabCreatedEvent(tab)) + + verify(caseExternalPluginTabRepository, never()).save(any()) + } + + @Test + fun `should skip side row creation for a malformed contentKey instead of throwing`() { + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, "not-a-uuid:overview") + + assertDoesNotThrow { + service.handleCaseTabCreatedEvent(CaseTabCreatedEvent(tab)) + } + + verify(caseExternalPluginTabRepository, never()).save(any()) + } + + @Test + fun `should re-point the side row on update of an EXTERNAL_PLUGIN tab`() { + val configurationId = UUID.randomUUID() + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, configurationId.toString()) + + service.handleCaseTabUpdatedEvent(CaseTabUpdatedEvent(tab)) + + val captor = argumentCaptor() + verify(caseExternalPluginTabRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(configurationId) + } + + @Test + fun `should skip side row update for a malformed contentKey instead of throwing`() { + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, "not-a-uuid") + + assertDoesNotThrow { + service.handleCaseTabUpdatedEvent(CaseTabUpdatedEvent(tab)) + } + + verify(caseExternalPluginTabRepository, never()).save(any()) + } + + @Test + fun `should delete stale side row when a tab is updated to a non-EXTERNAL_PLUGIN type`() { + val tab = caseTab(CaseTabType.WIDGETS, "my-widgets-tab") + val staleSideRow = CaseExternalPluginTab(tab.id, UUID.randomUUID()) + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn(Optional.of(staleSideRow)) + + service.handleCaseTabUpdatedEvent(CaseTabUpdatedEvent(tab)) + + verify(caseExternalPluginTabRepository).delete(staleSideRow) + verify(caseExternalPluginTabRepository, never()).save(any()) + } + + @Test + fun `should not delete anything when a non-EXTERNAL_PLUGIN tab has no side row`() { + val tab = caseTab(CaseTabType.STANDARD, "my-standard-tab") + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn(Optional.empty()) + + service.handleCaseTabUpdatedEvent(CaseTabUpdatedEvent(tab)) + + verify(caseExternalPluginTabRepository, never()).delete(any()) + } + + @Test + fun `should list usages for a configuration including the tab name`() { + val configurationId = UUID.randomUUID() + val tab = caseTab(CaseTabType.EXTERNAL_PLUGIN, configurationId.toString()) + val sideRow = CaseExternalPluginTab(tab.id, configurationId) + whenever(caseExternalPluginTabRepository.findAllByExternalPluginConfigurationId(configurationId)) + .thenReturn(listOf(sideRow)) + whenever(caseTabRepository.findById(tab.id)).thenReturn(Optional.of(tab)) + + val usages = service.findUsagesForConfiguration(configurationId) + + assertThat(usages).containsExactly( + CaseExternalPluginTabUsage( + configurationId = configurationId, + caseDefinitionKey = CASE_DEFINITION_ID.key, + caseDefinitionVersionTag = CASE_DEFINITION_ID.versionTag.toString(), + tabKey = tab.id.key, + tabName = tab.name, + ) + ) + } + + @Test + fun `should list usages with a null tab name when the parent tab is missing`() { + val configurationId = UUID.randomUUID() + val tabId = CaseTabId(CASE_DEFINITION_ID, "orphaned") + val sideRow = CaseExternalPluginTab(tabId, configurationId) + whenever(caseExternalPluginTabRepository.findAllByExternalPluginConfigurationId(configurationId)) + .thenReturn(listOf(sideRow)) + whenever(caseTabRepository.findById(tabId)).thenReturn(Optional.empty()) + + val usages = service.findUsagesForConfiguration(configurationId) + + assertThat(usages).hasSize(1) + assertThat(usages.single().tabName).isNull() + } + + @Test + fun `should return empty usages when no side rows reference the configuration`() { + val configurationId = UUID.randomUUID() + whenever(caseExternalPluginTabRepository.findAllByExternalPluginConfigurationId(configurationId)) + .thenReturn(emptyList()) + + assertThat(service.findUsagesForConfiguration(configurationId)).isEmpty() + } + + private fun caseTab(type: CaseTabType, contentKey: String) = CaseTab( + id = CaseTabId(CASE_DEFINITION_ID, "my-tab"), + name = "My tab", + tabOrder = 0, + type = type, + contentKey = contentKey, + ) + + private companion object { + val CASE_DEFINITION_ID: CaseDefinitionId = CaseDefinitionId.of("my-case-definition", "1.0.0") + } +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetServiceTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetServiceTest.kt new file mode 100644 index 0000000000..88f55022d0 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetServiceTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.ritense.authorization.AuthorizationService +import com.ritense.case.domain.CaseTabId +import com.ritense.case_.domain.tab.CaseWidgetTab +import com.ritense.case_.domain.tab.CaseWidgetTabWidget +import com.ritense.case_.repository.CaseWidgetTabRepository +import com.ritense.case_.rest.dto.CaseWidgetTabDto +import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto +import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginWidgetProperties +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class CaseWidgetServiceTest { + + private val caseWidgetTabRepository = mock() + + @Suppress("UNCHECKED_CAST") + private val mappers = listOf(ExternalPluginCaseWidgetMapper()) + as List> + + private val mappingResolver = mock() + + private val caseWidgetService = CaseWidgetService( + documentService = mock(), + caseWidgetTabRepository = caseWidgetTabRepository, + caseTabRepository = mock(), + authorizationService = mock(), + caseWidgetMappers = mappers, + caseWidgetDataProviders = emptyList(), + caseDefinitionChecker = mock(), + valueResolverService = mock(), + pluginConfigurationMappingResolvers = listOf(mappingResolver), + ) + + private val caseDefinitionId = CaseDefinitionId.of("my-case", "1.0.0") + + @Test + fun `updateWidgetTab rechecks configuration issues for the case definition`() { + val tabId = CaseTabId(caseDefinitionId, "widgets-tab") + whenever(caseWidgetTabRepository.findById(tabId)).thenReturn(Optional.of(CaseWidgetTab(tabId))) + whenever(caseWidgetTabRepository.save(any())).thenAnswer { it.arguments[0] } + + caseWidgetService.updateWidgetTab( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf( + ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = null, + width = 2, + highContrast = false, + isCompact = null, + properties = ExternalPluginWidgetProperties( + configurationId = UUID.randomUUID(), + bundleKey = null, + ), + ) + ), + ) + ) + + verify(mappingResolver).recheckIssuesForCaseDefinition(caseDefinitionId) + } +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt new file mode 100644 index 0000000000..89acce251f --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.service.CaseTabService +import com.ritense.case_.rest.dto.CaseWidgetTabDto +import com.ritense.case_.widget.custom.CustomCaseWidgetDto +import com.ritense.case_.widget.custom.CustomWidgetProperties +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto +import com.ritense.case_.widget.externalplugin.ExternalPluginWidgetProperties +import com.ritense.exporter.request.DocumentDefinitionExportRequest +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class CaseWidgetTabExporterTest { + + private val objectMapper = jacksonObjectMapper() + private val caseTabService = mock() + private val caseWidgetService = mock() + private val resolver = mock() + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @Test + fun `stamps the plugin id and version on external-plugin widgets in the export`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.of(resolver)) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId), customWidgetDto()), + ) + ) + whenever(resolver.resolvePluginDefinition(configurationId)) + .thenReturn(ExternalPluginTabDefinition("case-summary", "0.1.0")) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val widgets = exported[0]["widgets"] + val externalWidget = widgets.single { it["type"].asText() == "external-plugin" } + assertThat(externalWidget["properties"]["configurationId"].asText()).isEqualTo(configurationId.toString()) + assertThat(externalWidget["properties"]["pluginDefinitionKey"].asText()).isEqualTo("case-summary") + assertThat(externalWidget["properties"]["pluginDefinitionVersion"].asText()).isEqualTo("0.1.0") + + // Non-external widgets are untouched — no plugin identity noise. + val customWidget = widgets.single { it["type"].asText() == "custom" } + assertThat(customWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + @Test + fun `leaves the widget unchanged when the resolver cannot resolve the plugin definition`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.of(resolver)) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId)), + ) + ) + whenever(resolver.resolvePluginDefinition(configurationId)).thenReturn(null) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val externalWidget = exported[0]["widgets"].single() + assertThat(externalWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + @Test + fun `works without the external-plugin resolver on the classpath`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.empty()) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId)), + ) + ) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val externalWidget = exported[0]["widgets"].single() + assertThat(externalWidget["properties"]["configurationId"].asText()).isEqualTo(configurationId.toString()) + assertThat(externalWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + private fun widgetsTab() = CaseTab( + id = CaseTabId(caseDefinitionId, "widgets-tab"), + name = "Widgets", + tabOrder = 0, + type = CaseTabType.WIDGETS, + contentKey = "widgets-tab", + ) + + private fun externalPluginWidgetDto(configurationId: UUID) = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = null, + color = null, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = configurationId, + bundleKey = "summary-widget", + ), + ) + + private fun customWidgetDto() = CustomCaseWidgetDto( + key = "custom-widget", + title = "Custom", + icon = null, + color = null, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = CustomWidgetProperties(componentKey = "my-component"), + ) +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt new file mode 100644 index 0000000000..6d70198ce3 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.service + +import com.fasterxml.jackson.databind.jsontype.NamedType +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ritense.case_.domain.tab.CaseWidgetTab +import com.ritense.case_.domain.tab.CaseWidgetTabWidget +import com.ritense.case_.repository.CaseWidgetTabRepository +import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto +import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetMapper +import com.ritense.importer.ImportRequest +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import jakarta.validation.Validation +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.util.UUID + +class CaseWidgetTabImporterExternalPluginTest { + + private val objectMapper = jacksonObjectMapper().apply { + registerSubtypes(NamedType(ExternalPluginCaseWidgetDto::class.java, "external-plugin")) + } + private val validator = Validation.buildDefaultValidatorFactory().validator + private val caseWidgetTabRepository = mock() + + @Suppress("UNCHECKED_CAST") + private val mappers = listOf(ExternalPluginCaseWidgetMapper()) + as List> + + private val mappingResolver = mock() + + private lateinit var importer: CaseWidgetTabImporter + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @BeforeEach + fun before() { + importer = CaseWidgetTabImporter( + objectMapper, + validator, + caseWidgetTabRepository, + mappers, + listOf(mappingResolver), + ) + } + + @Test + fun `import remaps a mapped external-plugin widget configuration id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + + importer.import(request(json(sourceId), mapOf(sourceId to targetId))) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(targetId) + assertThat(widget.pluginDefinitionKey).isEqualTo("case-summary") + } + + @Test + fun `import leaves an unmapped external-plugin widget dangling on its original id`() { + val sourceId = UUID.randomUUID() + + importer.import(request(json(sourceId), mapOf(sourceId to null))) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(sourceId) + } + + @Test + fun `import leaves the id untouched when no mappings are given`() { + val sourceId = UUID.randomUUID() + + importer.import(request(json(sourceId), null)) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(sourceId) + } + + @Test + fun `afterImport rechecks configuration issues for the case definition`() { + importer.afterImport(request(json(UUID.randomUUID()), null)) + + verify(mappingResolver).recheckIssuesForCaseDefinition(caseDefinitionId) + } + + private fun savedExternalPluginWidget(): ExternalPluginCaseWidget { + val captor = argumentCaptor>() + verify(caseWidgetTabRepository).saveAll(captor.capture()) + return captor.firstValue.single().widgets.single() as ExternalPluginCaseWidget + } + + private fun request(content: String, mappings: Map?) = ImportRequest( + fileName = "config/case/my-case/1-0-0/case/widget-tab/my-case.case-widget-tab.json", + content = content.toByteArray(), + caseDefinitionId = caseDefinitionId, + pluginConfigurationMappings = mappings, + ) + + private fun json(configurationId: UUID) = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { + "configurationId": "$configurationId", + "bundleKey": "summary-widget", + "pluginDefinitionKey": "case-summary", + "pluginDefinitionVersion": "0.1.0" + } + } + ] + } + ] + """.trimIndent() +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt new file mode 100644 index 0000000000..f283b9d138 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.case_.rest.dto.ExternalPluginWidgetContentDto +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.widget.domain.WidgetColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.domain.Pageable +import java.util.Optional +import java.util.UUID + +class ExternalPluginCaseWidgetDataProviderTest { + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + private val documentId = UUID.randomUUID() + + @Test + fun `supports only external-plugin widgets`() { + val provider = ExternalPluginCaseWidgetDataProvider(Optional.empty()) + + assertThat(provider.supports(widget(UUID.randomUUID()))).isTrue() + assertThat(provider.supports("not a widget")).isFalse() + } + + @Test + fun `getData resolves the bundle url and builds the context`() { + val configurationId = UUID.randomUUID() + val resolver = mock() + whenever(resolver.resolveBundleUrl(configurationId, "summary-widget")) + .thenReturn("http://host/plugins/case-summary/0.1.0/bundles/case-widget.html") + val provider = ExternalPluginCaseWidgetDataProvider(Optional.of(resolver)) + + val result = provider.getData(documentId, widget(configurationId), Pageable.unpaged(), caseDefinitionId) + + assertThat(result).isInstanceOf(ExternalPluginWidgetContentDto::class.java) + result as ExternalPluginWidgetContentDto + assertThat(result.bundleUrl).isEqualTo("http://host/plugins/case-summary/0.1.0/bundles/case-widget.html") + assertThat(result.configurationId).isEqualTo(configurationId) + assertThat(result.bundleKey).isEqualTo("summary-widget") + assertThat(result.context.documentId).isEqualTo(documentId.toString()) + assertThat(result.context.caseDefinitionKey).isEqualTo("my-case") + assertThat(result.context.caseDefinitionVersionTag).isEqualTo("1.0.0") + assertThat(result.context.pluginConfigurationId).isEqualTo(configurationId.toString()) + } + + @Test + fun `getData returns a null bundle url when the resolver is absent`() { + val configurationId = UUID.randomUUID() + val provider = ExternalPluginCaseWidgetDataProvider(Optional.empty()) + + val result = provider.getData(documentId, widget(configurationId), Pageable.unpaged(), caseDefinitionId) as ExternalPluginWidgetContentDto + + assertThat(result.bundleUrl).isNull() + assertThat(result.configurationId).isEqualTo(configurationId) + } + + @Test + fun `getData does not call the resolver for a dangling widget with no configuration`() { + val resolver = mock() + val provider = ExternalPluginCaseWidgetDataProvider(Optional.of(resolver)) + + val result = provider.getData(documentId, widget(null), Pageable.unpaged(), caseDefinitionId) as ExternalPluginWidgetContentDto + + assertThat(result.bundleUrl).isNull() + assertThat(result.configurationId).isNull() + assertThat(result.context.pluginConfigurationId).isNull() + verify(resolver, never()).resolveBundleUrl(any(), any()) + } + + private fun widget(configurationId: UUID?) = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = null, + color = WidgetColor.WHITE, + order = 0, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt new file mode 100644 index 0000000000..fbe421f427 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.case_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.widget.domain.WidgetColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class ExternalPluginCaseWidgetMapperTest { + + private val mapper = ExternalPluginCaseWidgetMapper() + + @Test + fun `toEntity unpacks the properties into dedicated columns and sets order from the index`() { + val configurationId = UUID.randomUUID() + val dto = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = "mdi-account", + color = WidgetColor.BLUE, + width = 2, + highContrast = false, + isCompact = true, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val entity = mapper.toEntity(dto, 3) + + assertThat(entity.id.key).isEqualTo("summary-widget") + assertThat(entity.order).isEqualTo(3) + assertThat(entity.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(entity.bundleKey).isEqualTo("summary-widget") + assertThat(entity.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entity.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `toEntity defaults color to WHITE when none is given`() { + val dto = baseDto(color = null) + + assertThat(mapper.toEntity(dto, 0).color).isEqualTo(WidgetColor.WHITE) + } + + @Test + fun `toDto packs the columns back into properties`() { + val configurationId = UUID.randomUUID() + val entity = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = "mdi-account", + color = WidgetColor.BLUE, + order = 0, + width = 2, + highContrast = false, + isCompact = true, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + + val dto = mapper.toDto(entity) + + assertThat(dto.key).isEqualTo("summary-widget") + assertThat(dto.title).isEqualTo("Summary") + assertThat(dto.color).isEqualTo(WidgetColor.BLUE) + assertThat(dto.width).isEqualTo(2) + assertThat(dto.isCompact).isTrue() + assertThat(dto.properties.configurationId).isEqualTo(configurationId) + assertThat(dto.properties.bundleKey).isEqualTo("summary-widget") + assertThat(dto.properties.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.properties.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `entity to dto to entity round-trips the config, bundle and plugin identity`() { + val configurationId = UUID.randomUUID() + val entity = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = null, + color = WidgetColor.WHITE, + order = 5, + width = 4, + highContrast = true, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + + val roundTripped = mapper.toEntity(mapper.toDto(entity), 5) + + assertThat(roundTripped.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(roundTripped.bundleKey).isEqualTo("summary-widget") + assertThat(roundTripped.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(roundTripped.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(roundTripped.width).isEqualTo(4) + assertThat(roundTripped.highContrast).isTrue() + } + + private fun baseDto(color: WidgetColor? = null) = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = null, + color = color, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = UUID.randomUUID(), + bundleKey = "summary-widget", + ), + ) +} diff --git a/backend/contract/src/main/java/com/ritense/valtimo/contract/authentication/SystemPrincipal.java b/backend/contract/src/main/java/com/ritense/valtimo/contract/authentication/SystemPrincipal.java new file mode 100644 index 0000000000..5bfaaa6e1f --- /dev/null +++ b/backend/contract/src/main/java/com/ritense/valtimo/contract/authentication/SystemPrincipal.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimo.contract.authentication; + +/** + * Marker for a Spring Security principal that represents a non-human / system actor rather than a + * Keycloak user — for example an external plugin authenticated by a service token. Such a caller is + * authenticated (so it satisfies {@code .authenticated()} security rules) but has no user account, + * so {@link UserManagementService#getCurrentUser()} resolves it to the system user instead of + * attempting a user lookup that would fail. + */ +public interface SystemPrincipal { +} diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt index cdb0583a6d..bf92b16ec4 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt @@ -49,4 +49,14 @@ interface CaseDefinitionChecker { fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationType: String) { assertCanUpdateCaseDefinition(caseDefinitionId) } + + /** + * Multi-type variant for callers that repair several configuration-issue types in one operation + * (e.g. an external-plugin resolve that fixes service-task links, task-form links and case tabs + * together): a final case definition may be updated when *any* of the given types has an + * unresolved issue. + */ + fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationTypes: Collection) { + assertCanUpdateCaseDefinition(caseDefinitionId) + } } \ No newline at end of file diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/endpoint/EndpointDescription.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/endpoint/EndpointDescription.kt new file mode 100644 index 0000000000..b43ce5c610 --- /dev/null +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/endpoint/EndpointDescription.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimo.contract.endpoint + +/** + * Documents a REST endpoint with a human-readable description in English and Dutch, placed directly + * on the controller handler method that defines the endpoint. It is the single source of truth for + * the endpoint's description: the description shown to an admin when granting an external plugin + * access to specific endpoints is resolved from this annotation, and a test enforces that every + * endpoint declares one. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class EndpointDescription( + val en: String, + val nl: String, +) diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt index 455d015eca..6d1ad046f9 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt @@ -25,4 +25,11 @@ data class ImportPreviewContribution( val processDefinitionKey: String, val activityId: String, val existsInTargetEnvironment: Boolean, -) + val source: String = SOURCE_EMBEDDED, + val pluginDefinitionVersion: String? = null, +) { + companion object { + const val SOURCE_EMBEDDED = "embedded" + const val SOURCE_EXTERNAL = "external" + } +} diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt index cd7aeffe15..f9728e51f6 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt @@ -22,10 +22,26 @@ import java.util.UUID data class DanglingPluginConfigurationDto( val pluginDefinitionKey: String?, val sourcePluginConfigurationIds: Set, -) + val source: String = SOURCE_EMBEDDED, + val pluginDefinitionVersion: String? = null, +) { + companion object { + const val SOURCE_EMBEDDED = "embedded" + const val SOURCE_EXTERNAL = "external" + } +} interface PluginConfigurationMappingResolver { fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) fun getDanglingPluginConfigurations(caseDefinitionId: CaseDefinitionId): List fun recheckIssuesForProcessDefinition(processDefinitionId: String) + + /** + * Re-evaluates and (re)publishes this resolver's configuration issues for a whole case + * definition, independent of any process-link change. Lets surfaces that are not process links — + * e.g. external-plugin case tabs — get reliable, in-transaction issue detection at import time + * (triggered from their own importer) rather than depending on an incidental process-link event. + * Default no-op for resolvers with nothing to recheck at case-definition granularity. + */ + fun recheckIssuesForCaseDefinition(caseDefinitionId: CaseDefinitionId) {} } diff --git a/backend/core/src/main/java/com/ritense/valtimo/emailnotificationsettings/web/rest/EmailNotificationSettingsResource.java b/backend/core/src/main/java/com/ritense/valtimo/emailnotificationsettings/web/rest/EmailNotificationSettingsResource.java index f5115b5e99..44e6048360 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/emailnotificationsettings/web/rest/EmailNotificationSettingsResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/emailnotificationsettings/web/rest/EmailNotificationSettingsResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.contract.utils.SecurityUtils; import com.ritense.valtimo.emailnotificationsettings.domain.request.impl.EmailNotificationSettings; import com.ritense.valtimo.emailnotificationsettings.domain.request.impl.EmailNotificationSettingsRequestImpl; @@ -42,6 +43,10 @@ public EmailNotificationSettingsResource(EmailNotificationSettingsService emailN this.emailNotificationService = emailNotificationService; } + @EndpointDescription( + en = "Get email notification settings", + nl = "E-mailnotificatie-instellingen ophalen" + ) @GetMapping("/email-notification-settings") public ResponseEntity getSettingsFor() { final String emailAddress = SecurityUtils.getCurrentUserLogin(); @@ -50,6 +55,10 @@ public ResponseEntity getSettingsFor() .orElse(ResponseEntity.noContent().build()); } + @EndpointDescription( + en = "Update email notification settings", + nl = "E-mailnotificatie-instellingen bijwerken" + ) @PutMapping("/email-notification-settings") public ResponseEntity process( @RequestBody @Valid EmailNotificationSettingsRequestImpl request diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/AccountResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/AccountResource.java index 54fd6908e9..99fc13c4fc 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/AccountResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/AccountResource.java @@ -24,6 +24,7 @@ import com.ritense.valtimo.contract.authentication.ManageableUser; import com.ritense.valtimo.contract.authentication.UserManagementService; import com.ritense.valtimo.contract.authentication.model.Profile; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.http.ResponseEntity; @@ -45,18 +46,30 @@ public AccountResource(CurrentUserService currentUserService) { this.currentUserService = currentUserService; } + @EndpointDescription( + en = "Get the current user account", + nl = "Account van huidige gebruiker ophalen" + ) @GetMapping("/v1/account") public ResponseEntity getAccount() throws IllegalAccessException { final ManageableUser currentUser = currentUserService.getCurrentUser(); return ResponseEntity.ok(currentUser); } + @EndpointDescription( + en = "Update the current user profile", + nl = "Profiel van huidige gebruiker bijwerken" + ) @PostMapping("/v1/account/profile") public ResponseEntity updateProfile(@Valid @RequestBody Profile profile) throws IllegalAccessException { currentUserService.updateProfile(profile); return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Change the current user password", + nl = "Wachtwoord van huidige gebruiker wijzigen" + ) @PostMapping(value = "/v1/account/change_password", produces = TEXT_PLAIN_UTF8_VALUE) public ResponseEntity changePassword(@RequestBody String password) throws IllegalAccessException { currentUserService.changePassword(password); diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldResource.java index 555c52e9df..7593697719 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.domain.choicefield.ChoiceField; import com.ritense.valtimo.service.ChoiceFieldService; import com.ritense.valtimo.web.rest.dto.ChoiceFieldCreateRequestDTO; @@ -60,6 +61,10 @@ public ChoiceFieldResource(ChoiceFieldService choiceFieldService) { this.choiceFieldService = choiceFieldService; } + @EndpointDescription( + en = "Create a choice field", + nl = "Keuzeveld aanmaken" + ) @PostMapping("/v1/choice-fields") public ResponseEntity createChoiceField( @Valid @RequestBody ChoiceFieldCreateRequestDTO choiceFieldCreateRequestDTO @@ -71,6 +76,10 @@ public ResponseEntity createChoiceField( .body(result); } + @EndpointDescription( + en = "Update a choice field", + nl = "Keuzeveld bijwerken" + ) @PutMapping("/v1/choice-fields") public ResponseEntity updateChoiceField(@Valid @RequestBody ChoiceFieldUpdateRequestDTO choiceFieldUpdateRequestDTO) { logger.debug("REST request to update ChoiceField : {}", choiceFieldUpdateRequestDTO); @@ -85,6 +94,10 @@ public ResponseEntity updateChoiceField(@Valid @RequestBody ChoiceF * * @deprecated since 12.0.0, use v2 instead */ + @EndpointDescription( + en = "List all choice fields", + nl = "Alle keuzevelden ophalen" + ) @GetMapping("/v1/choice-fields") @Deprecated(since = "12.0.0", forRemoval = true) public ResponseEntity> getAllChoiceFields(Pageable pageable) { @@ -94,6 +107,10 @@ public ResponseEntity> getAllChoiceFields(Pageable pageable) { return new ResponseEntity<>(page.getContent(), headers, HttpStatus.OK); } + @EndpointDescription( + en = "List all choice fields paged", + nl = "Alle keuzevelden gepagineerd ophalen" + ) @GetMapping("/v2/choice-fields") public ResponseEntity> getAllChoiceFieldsPaged(Pageable pageable) { logger.debug("REST request to get a page of ChoiceFields"); @@ -101,6 +118,10 @@ public ResponseEntity> getAllChoiceFieldsPaged(Pageable pageab return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Get a choice field by id", + nl = "Keuzeveld op id ophalen" + ) @GetMapping("/v1/choice-fields/{id}") public ResponseEntity getChoiceField(@PathVariable Long id) { logger.debug("REST request to get ChoiceField : {}", id); @@ -110,6 +131,10 @@ public ResponseEntity getChoiceField(@PathVariable Long id) { .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); } + @EndpointDescription( + en = "Get a choice field by name", + nl = "Keuzeveld op naam ophalen" + ) @GetMapping("/v1/choice-fields/name/{name}") public ResponseEntity getChoiceFieldByName(@PathVariable String name) { logger.debug("REST request to get ChoiceField : {}", name); @@ -119,6 +144,10 @@ public ResponseEntity getChoiceFieldByName(@PathVariable String .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); } + @EndpointDescription( + en = "Delete a choice field", + nl = "Keuzeveld verwijderen" + ) @DeleteMapping("/v1/choice-fields/{id}") public ResponseEntity deleteChoiceField(@PathVariable Long id) { logger.debug("REST request to delete ChoiceField : {}", id); diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldValueResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldValueResource.java index 0c61c32756..64233887c4 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldValueResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ChoiceFieldValueResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.domain.choicefield.ChoiceFieldValue; import com.ritense.valtimo.service.ChoiceFieldValueService; import com.ritense.valtimo.web.rest.dto.ChoiceFieldValueCreateRequestDTO; @@ -60,6 +61,10 @@ public ChoiceFieldValueResource( this.choiceFieldValueService = choiceFieldValueService; } + @EndpointDescription( + en = "Create a choice field value", + nl = "Keuzeveldwaarde aanmaken" + ) @PostMapping("/v1/choice-field-values") public ResponseEntity createChoiceFieldValue( @Valid @RequestBody ChoiceFieldValueCreateRequestDTO requestDTO, @@ -72,6 +77,10 @@ public ResponseEntity createChoiceFieldValue( .body(result); } + @EndpointDescription( + en = "Update a choice field value", + nl = "Keuzeveldwaarde bijwerken" + ) @PutMapping("/v1/choice-field-values") public ResponseEntity updateChoiceFieldValue( @Valid @RequestBody ChoiceFieldValueUpdateRequestDTO requestDTO, @@ -89,6 +98,10 @@ public ResponseEntity updateChoiceFieldValue( * * @deprecated since 12.0.0, use v2 instead */ + @EndpointDescription( + en = "List all choice field values", + nl = "Alle keuzeveldwaarden ophalen" + ) @GetMapping("/v1/choice-field-values") @Deprecated(since = "12.0.0", forRemoval = true) public ResponseEntity> getAllChoiceFieldValues(Pageable pageable) { @@ -98,6 +111,10 @@ public ResponseEntity> getAllChoiceFieldValues(Pageable p return ResponseEntity.ok().headers(headers).body(page.getContent()); } + @EndpointDescription( + en = "List all choice field values paged", + nl = "Alle keuzeveldwaarden gepagineerd ophalen" + ) @GetMapping("/v2/choice-field-values") public ResponseEntity> getAllChoiceFieldValuesPaged(Pageable pageable) { logger.debug("REST request to get a page of ChoiceFieldValues"); @@ -105,6 +122,10 @@ public ResponseEntity> getAllChoiceFieldValuesPaged(Pagea return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Get a choice field value by id", + nl = "Keuzeveldwaarde op id ophalen" + ) @GetMapping("/v1/choice-field-values/{id}") public ResponseEntity getChoiceFieldValue(@PathVariable Long id) { logger.debug("REST request to get ChoiceFieldValue : {}", id); @@ -113,6 +134,10 @@ public ResponseEntity getChoiceFieldValue(@PathVariable Long i .orElse(ResponseEntity.notFound().build()); } + @EndpointDescription( + en = "Delete a choice field value", + nl = "Keuzeveldwaarde verwijderen" + ) @DeleteMapping("/v1/choice-field-values/{id}") public ResponseEntity deleteChoiceFieldValue(@PathVariable Long id) { logger.debug("REST request to delete ChoiceFieldValue : {}", id); @@ -120,6 +145,10 @@ public ResponseEntity deleteChoiceFieldValue(@PathVariable Long id) { return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(CHOICE_FIELD_VALUE, id.toString())).build(); } + @EndpointDescription( + en = "Get a choice field value by field name and value", + nl = "Keuzeveldwaarde op veldnaam en waarde ophalen" + ) @GetMapping("/v1/choice-field-values/choice-field/{choicefield_name}/value/{value}") public ResponseEntity getChoiceFieldValuesByChoiceField( @PathVariable(name = "choicefield_name") String choiceFieldName, @@ -135,6 +164,10 @@ public ResponseEntity getChoiceFieldValuesByChoiceField( * * @deprecated since 12.0.0, use v2 instead */ + @EndpointDescription( + en = "List choice field values for a choice field", + nl = "Keuzeveldwaarden voor een keuzeveld ophalen" + ) @GetMapping("/v1/choice-field-values/{choice_field_name}/values") @Deprecated(since = "12.0.0", forRemoval = true) public ResponseEntity> getChoiceFieldValuesByChoiceField( @@ -147,6 +180,10 @@ public ResponseEntity> getChoiceFieldValuesByChoiceField( return ResponseEntity.ok().headers(headers).body(page.getContent()); } + @EndpointDescription( + en = "List choice field values for a choice field paged", + nl = "Keuzeveldwaarden voor een keuzeveld gepagineerd ophalen" + ) @GetMapping("/v2/choice-field-values/{choice_field_name}/values") public ResponseEntity> getChoiceFieldValuesByChoiceFieldPaged( Pageable pageable, diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/PingResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/PingResource.java index 077a58df6d..4163330289 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/PingResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/PingResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.TEXT_PLAIN_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,6 +39,10 @@ public class PingResource { private static final String PING_RESPONSE = "pong"; + @EndpointDescription( + en = "Health check ping endpoint", + nl = "Statuscontrole ping-endpoint" + ) @GetMapping(produces = TEXT_PLAIN_UTF8_VALUE) @ResponseStatus(HttpStatus.OK) public String pingPong() { diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessInstanceResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessInstanceResource.java index 67b76a9d29..543fc6a0df 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessInstanceResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessInstanceResource.java @@ -23,6 +23,7 @@ import com.ritense.valtimo.operaton.domain.OperatonExecution; import com.ritense.valtimo.operaton.service.OperatonRuntimeService; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.util.List; import java.util.Map; import org.springframework.http.ResponseEntity; @@ -43,6 +44,10 @@ public ProcessInstanceResource(OperatonRuntimeService runtimeService) { this.runtimeService = runtimeService; } + @EndpointDescription( + en = "Get process instance variables", + nl = "Procesinstantie-variabelen ophalen" + ) @PostMapping("/v1/process-instance/{id}/variables") public ResponseEntity> getProcessInstanceVariables( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String id, diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessResource.java index 0dc033430f..31c80a5224 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ProcessResource.java @@ -40,6 +40,7 @@ import com.ritense.valtimo.operaton.service.OperatonHistoryService; import com.ritense.valtimo.operaton.service.OperatonRepositoryService; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.contract.exception.DocumentParserException; import com.ritense.valtimo.contract.exception.ProcessNotFoundException; import com.ritense.valtimo.exception.BpmnParseException; @@ -152,6 +153,10 @@ public ProcessResource( this.processPropertyService = processPropertyService; } + @EndpointDescription( + en = "List process definitions", + nl = "Procesdefinities ophalen" + ) @GetMapping("/v1/process/definition") public ResponseEntity> getProcessDefinitions() { final List definitions = runWithoutAuthorization(() -> operatonProcessService @@ -165,6 +170,10 @@ public ResponseEntity> getProcessDefini return ResponseEntity.ok(definitions); } + @EndpointDescription( + en = "Get a process definition by key", + nl = "Procesdefinitie op sleutel ophalen" + ) @GetMapping("/v1/process/definition/{processDefinitionKey}") public ResponseEntity getProcessDefinition( @LoggableResource(resourceTypeName = "processDefinitionKey") @PathVariable String processDefinitionKey @@ -179,6 +188,10 @@ public ResponseEntity getProcessDefinition( .orElse(ResponseEntity.notFound().build()); } + @EndpointDescription( + en = "List process definition versions", + nl = "Versies van procesdefinitie ophalen" + ) @GetMapping("/v1/process/definition/{processDefinitionKey}/versions") public ResponseEntity> getProcessDefinitionVersions( @LoggableResource(resourceTypeName = "processDefinitionKey") @PathVariable String processDefinitionKey @@ -193,6 +206,10 @@ public ResponseEntity> getProcessDefinitionVe return ResponseEntity.ok(result); } + @EndpointDescription( + en = "Get process definition XML diagram", + nl = "XML-diagram van procesdefinitie ophalen" + ) @GetMapping("/v1/process/definition/{processDefinitionId}/xml") public ResponseEntity getProcessDefinitionXml( @LoggableResource(resourceType = OperatonProcessDefinition.class) @PathVariable String processDefinitionId @@ -213,6 +230,10 @@ public ResponseEntity getProcessDefinit } } + @EndpointDescription( + en = "Get flow nodes for process migration", + nl = "Flow nodes voor procesmigratie ophalen" + ) @GetMapping("/v1/process/definition/{sourceProcessDefinitionId}/{targetProcessDefinitionId}/flownodes") public ResponseEntity getFlowNodes( @LoggableResource(resourceType = OperatonProcessDefinition.class) @PathVariable String sourceProcessDefinitionId, @@ -228,6 +249,10 @@ public ResponseEntity getFlowNodes( return ResponseEntity.ok(flowNodeMigrationDTO); } + @EndpointDescription( + en = "Get process definition task count heatmap", + nl = "Heatmap met taakaantallen van procesdefinitie ophalen" + ) @GetMapping("/v1/process/definition/{processDefinitionKey}/heatmap/count") public ResponseEntity> getProcessDefinitionHeatmap( @LoggableResource(resourceTypeName = "processDefinitionKey") @PathVariable String processDefinitionKey, @@ -288,6 +313,10 @@ public ResponseEntity> getProcessDefinitionHeat return ResponseEntity.ok(activeTasksCount); } + @EndpointDescription( + en = "Get process definition task duration heatmap", + nl = "Heatmap met taakduur van procesdefinitie ophalen" + ) @GetMapping("/v1/process/definition/{processDefinitionKey}/heatmap/duration") public ResponseEntity> getProcessDefinitionDurationBasedHeatmap( @LoggableResource(resourceTypeName = "processDefinitionKey") @PathVariable String processDefinitionKey, @@ -366,6 +395,10 @@ public ResponseEntity> getProcessDefi return ResponseEntity.ok(allTasksAverageDuration); } + @EndpointDescription( + en = "Start a process instance", + nl = "Procesinstantie starten" + ) @PostMapping(value = "/v1/process/definition/{processDefinitionKey}/{businessKey}/start", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity startProcessInstance( @LoggableResource(resourceTypeName = "processDefinitionKey") @PathVariable String processDefinitionKey, @@ -376,6 +409,10 @@ public ResponseEntity startProcessInstance( return ResponseEntity.ok(processInstanceWithDefinition.getProcessInstanceDto()); } + @EndpointDescription( + en = "Get a process instance", + nl = "Procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}") public ResponseEntity getProcessInstance( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -389,6 +426,10 @@ public ResponseEntity getProcessInstance( .orElse(ResponseEntity.notFound().build()); } + @EndpointDescription( + en = "Get process instance activity history", + nl = "Activiteitenhistorie van procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/history") public ResponseEntity> getProcessInstanceHistory( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -409,6 +450,10 @@ public ResponseEntity> getProcessInstanceHisto return ResponseEntity.ok(result); } + @EndpointDescription( + en = "Get process instance operation log", + nl = "Bewerkingslogboek van procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/log") public ResponseEntity> getProcessInstanceOperationLog( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -422,6 +467,10 @@ public ResponseEntity> getProcessInstanceOperatio return ResponseEntity.ok(result); } + @EndpointDescription( + en = "List tasks for a process instance", + nl = "Taken voor een procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/tasks") public ResponseEntity> getProcessInstanceTasks( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -435,6 +484,10 @@ public ResponseEntity> getProcessInstanceTask ); } + @EndpointDescription( + en = "Get the active task of a process instance", + nl = "Actieve taak van een procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/activetask") public ResponseEntity getProcessInstanceActiveTask( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -450,6 +503,10 @@ public ResponseEntity getProcessInstanceActiveTask( .orElse(ResponseEntity.noContent().build()); } + @EndpointDescription( + en = "Get process instance XML diagram", + nl = "XML-diagram van procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/xml") public ResponseEntity getProcessInstanceXml( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -474,6 +531,10 @@ public ResponseEntity getProcessInstanceXml( } } + @EndpointDescription( + en = "Get process instance activity tree", + nl = "Activiteitenstructuur van procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/activities") public ResponseEntity getProcessInstanceActivity( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -490,6 +551,10 @@ public ResponseEntity getProcessInstanceActivity( * @deprecated Task comments will be removed in the future. */ @Deprecated(since = "11.1.0", forRemoval = true) + @EndpointDescription( + en = "List comments for a process instance", + nl = "Opmerkingen voor een procesinstantie ophalen" + ) @GetMapping("/v1/process/{processInstanceId}/comments") public ResponseEntity> getProcessInstanceComments( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId @@ -504,6 +569,10 @@ public ResponseEntity> getProcessInstanceComments( * * @deprecated since 12.0.0, use v2 instead */ + @EndpointDescription( + en = "Search process instances", + nl = "Procesinstanties zoeken" + ) @PostMapping("/v1/process/{processDefinitionName}/search") @Deprecated(since = "12.0.0", forRemoval = true) public ResponseEntity> searchProcessInstancesV2( @@ -521,6 +590,10 @@ public ResponseEntity> searchProcessInstancesV2( return ResponseEntity.ok().headers(headers).body(page.getContent()); } + @EndpointDescription( + en = "Search process instances paged", + nl = "Procesinstanties gepagineerd zoeken" + ) @PostMapping("/v2/process/{processDefinitionName}/search") public ResponseEntity> searchProcessInstancesPaged( @LoggableResource(resourceTypeName = "processDefinitionName") @PathVariable String processDefinitionName, @@ -535,6 +608,10 @@ public ResponseEntity> searchProcessInstancesPaged( return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Count process instances by definition name", + nl = "Procesinstanties op definitienaam tellen" + ) @PostMapping("/v1/process/{processDefinitionName}/count") public ResponseEntity searchProcessInstanceCountV2( @LoggableResource(resourceTypeName = "processDefinitionName") @PathVariable String processDefinitionName, @@ -547,6 +624,10 @@ public ResponseEntity searchProcessInstanceCountV2( return ResponseEntity.ok(new ResultCount(count)); } + @EndpointDescription( + en = "Count process instances by definition id", + nl = "Procesinstanties op definitie-id tellen" + ) @PostMapping("/v1/process/definition/{processDefinitionId}/count") public ResponseEntity getProcessInstanceCountForProcessDefinitionIdV2( @LoggableResource(resourceType = OperatonProcessDefinition.class) @PathVariable String processDefinitionId, @@ -557,6 +638,10 @@ public ResponseEntity getProcessInstanceCountForProcessDefinitionId return ResponseEntity.ok(new ResultCount(count)); } + @EndpointDescription( + en = "Migrate process instances between definitions", + nl = "Procesinstanties tussen definities migreren" + ) @PostMapping("/v1/process/definition/{sourceProcessDefinitionId}/{targetProcessDefinitionId}/migrate") @ResponseBody @Transactional @@ -590,6 +675,10 @@ public ResponseEntity migrateProcessInstancesByProcessDefinitionIds( * @deprecated Task comments will be removed in the future. */ @Deprecated(since = "11.1.0", forRemoval = true) + @EndpointDescription( + en = "Create a comment on a process instance", + nl = "Opmerking bij een procesinstantie aanmaken" + ) @PostMapping("/v1/process/{processInstanceId}/comment") public ResponseEntity createComment( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId, @@ -599,6 +688,10 @@ public ResponseEntity createComment( return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Delete a process instance", + nl = "Procesinstantie verwijderen" + ) @PostMapping("/v1/process/{processInstanceId}/delete") public ResponseEntity delete( @LoggableResource(resourceType = OperatonExecution.class) @PathVariable String processInstanceId, @@ -610,6 +703,10 @@ public ResponseEntity delete( return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Deploy short timer version of process definition", + nl = "Versie met korte timer van procesdefinitie uitrollen" + ) @PutMapping("/v1/process/definition/{processDefinitionId}/xml/timer") public ResponseEntity modifyProcessDefinitionIntoShortTimerVersionAndDeploy( @LoggableResource(resourceType = OperatonProcessDefinition.class) @PathVariable String processDefinitionId @@ -621,6 +718,10 @@ public ResponseEntity modifyProcessDefinitionIntoShortTimerVersionAndDeplo return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Deploy a process definition", + nl = "Procesdefinitie uitrollen" + ) @PostMapping(value = "/v1/process/definition/deployment", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity deployProcessDefinition( @RequestPart(name = "file") MultipartFile bpmn) { diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ReportingResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ReportingResource.java index 1f74719737..7566739690 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/ReportingResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/ReportingResource.java @@ -27,6 +27,7 @@ import com.ritense.valtimo.operaton.repository.OperatonHistoricProcessInstanceSpecificationHelper; import com.ritense.valtimo.operaton.service.OperatonHistoryService; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.repository.OperatonReportingRepository; import com.ritense.valtimo.repository.operaton.dto.ChartInstance; import com.ritense.valtimo.repository.operaton.dto.ChartInstanceSeries; @@ -79,6 +80,10 @@ public ReportingResource( this.operatonReportingRepository = operatonReportingRepository; } + @EndpointDescription( + en = "Get process instance counts report", + nl = "Rapportage van procesinstantie-aantallen ophalen" + ) @GetMapping("/v1/reporting/instancecount") public ResponseEntity instanceCount( @RequestParam(value = "processFilter", required = false) String processId @@ -87,6 +92,10 @@ public ResponseEntity instanceCount( return new ResponseEntity<>(instanceCounts, HttpStatus.OK); } + @EndpointDescription( + en = "Get process instance statistics report", + nl = "Rapportage van procesinstantie-statistieken ophalen" + ) @GetMapping("/v1/reporting/instancesstatistics") public ResponseEntity> instanceStatistics( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @@ -116,6 +125,10 @@ public ResponseEntity> instanceStatistics( return new ResponseEntity<>(processInstanceStatisticsList, HttpStatus.OK); } + @EndpointDescription( + en = "Get average task duration report", + nl = "Rapportage van gemiddelde taakduur ophalen" + ) @GetMapping("/v1/reporting/tasksAverage") public ResponseEntity tasksHistory( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @@ -157,6 +170,10 @@ public ResponseEntity tasksHistory( return new ResponseEntity<>(new ChartInstance(categories, series), HttpStatus.OK); } + @EndpointDescription( + en = "Get tasks per person report", + nl = "Rapportage van taken per persoon ophalen" + ) @GetMapping("/v1/reporting/tasksPerPerson") public ResponseEntity tasksPerPerson( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @@ -198,6 +215,10 @@ public ResponseEntity tasksPerPerson( return new ResponseEntity<>(new ChartInstance(categories, series), HttpStatus.OK); } + @EndpointDescription( + en = "Get pending tasks by role report", + nl = "Rapportage van openstaande taken per rol ophalen" + ) @GetMapping("/v1/reporting/pendingTasksByRole") public ResponseEntity pendingTasksByRole( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @@ -207,6 +228,10 @@ public ResponseEntity pendingTasksByRole( return new ResponseEntity<>(operatonReportingRepository.getTasksPerRole(processId, fromDate, toDate), HttpStatus.OK); } + @EndpointDescription( + en = "Get unfinished tasks per type report", + nl = "Rapportage van onafgeronde taken per type ophalen" + ) @GetMapping("/v1/reporting/unfinishedTasksPerType") public ResponseEntity unfinishedTasksPerType( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, @@ -242,6 +267,10 @@ public ResponseEntity unfinishedTasksPerType( return new ResponseEntity<>(new ChartInstance(categories, series), HttpStatus.OK); } + @EndpointDescription( + en = "Get finished and unfinished instances report", + nl = "Rapportage van afgeronde en onafgeronde instanties ophalen" + ) @GetMapping("/v1/reporting/finishedAndUnfinishedInstances") public ResponseEntity finishedAndUnfinishedInstances( @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/TaskResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/TaskResource.java index d7974d7459..20db2b6ef4 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/TaskResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/TaskResource.java @@ -28,6 +28,7 @@ import com.ritense.valtimo.contract.authentication.ManageableUser; import com.ritense.valtimo.contract.authentication.NamedUser; import com.ritense.valtimo.contract.authentication.UserManagementService; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.operaton.dto.TeamDto; import com.ritense.valtimo.security.exceptions.TaskNotFoundException; import com.ritense.valtimo.service.OperatonProcessService; @@ -83,6 +84,10 @@ public TaskResource( * * @deprecated since 12.0.0, use v2 instead */ + @EndpointDescription( + en = "List tasks filtered", + nl = "Gefilterde taken ophalen" + ) @GetMapping("/v1/task") @Deprecated(since = "12.0.0", forRemoval = true) public ResponseEntity> getTasks( @@ -94,6 +99,10 @@ public ResponseEntity> getTasks( return ResponseEntity.ok().headers(headers).body(page.getContent()); } + @EndpointDescription( + en = "List tasks filtered paged", + nl = "Gefilterde taken gepagineerd ophalen" + ) @GetMapping("/v2/task") public ResponseEntity> getTasksPaged( @RequestParam OperatonTaskService.TaskFilter filter, @@ -103,6 +112,10 @@ public ResponseEntity> getTasksPaged( return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Get a task by id", + nl = "Taak op id ophalen" + ) @GetMapping("/v1/task/{taskId}") public ResponseEntity getTask( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId, @@ -121,6 +134,10 @@ public ResponseEntity getTask( return ResponseEntity.ok(customTaskDto); } + @EndpointDescription( + en = "Assign a task to a user or team", + nl = "Taak toewijzen aan een gebruiker of team" + ) @PostMapping("/v1/task/{taskId}/assign") public ResponseEntity assign( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId, @@ -143,6 +160,10 @@ public ResponseEntity assign( return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Assign multiple tasks at once", + nl = "Meerdere taken tegelijk toewijzen" + ) @PostMapping("/v1/task/assign/batch-assign") public ResponseEntity batchClaim(@Valid @RequestBody BatchAssignTaskDTO batchAssignTaskDTO) { final String assignee = batchAssignTaskDTO.getAssignee(); @@ -158,6 +179,10 @@ public ResponseEntity batchClaim(@Valid @RequestBody BatchAssignTaskDTO ba return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Unassign a task", + nl = "Toewijzing van een taak ongedaan maken" + ) @PostMapping("/v1/task/{taskId}/unassign") public ResponseEntity unassign( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId @@ -167,6 +192,10 @@ public ResponseEntity unassign( return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Complete a task", + nl = "Taak afronden" + ) @PostMapping("/v1/task/{taskId}/complete") public ResponseEntity complete( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId, @@ -176,6 +205,10 @@ public ResponseEntity complete( return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Complete multiple tasks at once", + nl = "Meerdere taken tegelijk afronden" + ) @PostMapping("/v1/task/batch-complete") public ResponseEntity batchComplete(@RequestBody List taskIdList) { taskIdList.forEach(taskId -> { @@ -188,6 +221,10 @@ public ResponseEntity batchComplete(@RequestBody List taskIdList) return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Set the due date of a task", + nl = "Vervaldatum van een taak instellen" + ) @PostMapping("/v1/task/{taskId}/set-due-date") public ResponseEntity setDueDate( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId, @@ -207,6 +244,10 @@ public ResponseEntity setDueDate( * @deprecated Task comments will be removed in the future. */ @Deprecated(since = "11.1.0", forRemoval = true) + @EndpointDescription( + en = "List comments for a task", + nl = "Opmerkingen voor een taak ophalen" + ) @GetMapping("/v1/task/{taskId}/comments") public ResponseEntity> getProcessInstanceComments( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId @@ -219,6 +260,10 @@ public ResponseEntity> getProcessInstanceComments( } @Deprecated(since = "10.8.0", forRemoval = true) + @EndpointDescription( + en = "List candidate users for a task", + nl = "Kandidaat-gebruikers voor een taak ophalen" + ) @GetMapping("/v1/task/{taskId}/candidate-user") public ResponseEntity> getTaskCandidateUsers( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId @@ -227,6 +272,10 @@ public ResponseEntity> getTaskCandidateUsers( return ResponseEntity.ok(users); } + @EndpointDescription( + en = "List candidate users for a task", + nl = "Kandidaat-gebruikers voor een taak ophalen" + ) @GetMapping("/v2/task/{taskId}/candidate-user") public ResponseEntity> getNamedCandidateUsers( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId @@ -235,6 +284,10 @@ public ResponseEntity> getNamedCandidateUsers( return ResponseEntity.ok(users); } + @EndpointDescription( + en = "List candidate teams for a task", + nl = "Kandidaat-teams voor een taak ophalen" + ) @GetMapping("/v1/task/{taskId}/candidate-team") public ResponseEntity> getCandidateTeams( @LoggableResource(resourceType = OperatonTask.class) @PathVariable String taskId, diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/UserResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/UserResource.java index 75624086e6..87cc2e8a40 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/UserResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/UserResource.java @@ -26,6 +26,7 @@ import com.ritense.valtimo.contract.authentication.ManageableUser; import com.ritense.valtimo.contract.authentication.UserManagementService; import com.ritense.valtimo.contract.authentication.model.ValtimoUser; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.service.UserSettingsService; import com.ritense.valtimo.web.rest.dto.UserTeamDto; import com.ritense.valtimo.web.rest.util.HeaderUtil; @@ -71,6 +72,10 @@ public UserResource( this.objectMapper = objectMapper; } + @EndpointDescription( + en = "Create a user", + nl = "Gebruiker aanmaken" + ) @Deprecated(since = "Since 13.20.0", forRemoval = true) @PostMapping("/v1/users") public ResponseEntity createUser(@Valid @RequestBody ValtimoUser valtimoUser) throws URISyntaxException { @@ -81,6 +86,10 @@ public ResponseEntity createUser(@Valid @RequestBody ValtimoUser return ResponseEntity.created(uri).headers(headers).body(user); } + @EndpointDescription( + en = "Update a user", + nl = "Gebruiker bijwerken" + ) @Deprecated(since = "Since 13.20.0", forRemoval = true) @PutMapping("/v1/users") public ResponseEntity updateUser(@Valid @RequestBody ValtimoUser valtimoUser) { @@ -90,6 +99,10 @@ public ResponseEntity updateUser(@Valid @RequestBody ValtimoUser return ResponseEntity.ok().headers(headers).body(user); } + @EndpointDescription( + en = "Activate a user", + nl = "Gebruiker activeren" + ) @Deprecated(since = "Since 13.20.0", forRemoval = true) @PutMapping("/v1/users/{userId}/activate") public ResponseEntity activateUser(@PathVariable String userId) { @@ -99,6 +112,10 @@ public ResponseEntity activateUser(@PathVariable String userId) { return ResponseEntity.ok().headers(headers).build(); } + @EndpointDescription( + en = "Deactivate a user", + nl = "Gebruiker deactiveren" + ) @Deprecated(since = "Since 13.20.0", forRemoval = true) @PutMapping("/v1/users/{userId}/deactivate") public ResponseEntity deactivateUser(@PathVariable String userId) { @@ -108,18 +125,30 @@ public ResponseEntity deactivateUser(@PathVariable String userId) { return ResponseEntity.ok().headers(headers).build(); } + @EndpointDescription( + en = "List all users", + nl = "Alle gebruikers ophalen" + ) @GetMapping("/v1/users") public ResponseEntity> getAllUsers(Pageable pageable) throws URISyntaxException { final Page page = userManagementService.getAllUsers(pageable); return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Search users by search term", + nl = "Gebruikers op zoekterm zoeken" + ) @GetMapping(value = "/v1/users", params = {"searchTerm"}) public ResponseEntity> queryUsers(@RequestParam("searchTerm") String searchTerm, Pageable pageable) { final Page page = userManagementService.queryUsers(searchTerm, pageable); return ResponseEntity.ok(page); } + @EndpointDescription( + en = "Get a user by email", + nl = "Gebruiker op e-mailadres ophalen" + ) @GetMapping("/v1/users/email/{email}/") public ResponseEntity getUserByEmail(@PathVariable String email) { logger.debug("Request to get user by email : {}", email); @@ -128,6 +157,10 @@ public ResponseEntity getUserByEmail(@PathVariable String email) .orElseGet(() -> ResponseEntity.notFound().build()); } + @EndpointDescription( + en = "Get a user by id", + nl = "Gebruiker op id ophalen" + ) @GetMapping("/v1/users/{userId}") public ResponseEntity getUser(@PathVariable String userId) { logger.debug("Request to get user by id : {}", userId); @@ -135,6 +168,10 @@ public ResponseEntity getUser(@PathVariable String userId) { return ResponseEntity.ok(manageableUser); } + @EndpointDescription( + en = "List users by role", + nl = "Gebruikers op rol ophalen" + ) @GetMapping("/v1/users/authority/{authority}") public ResponseEntity> getAllUsersByRole(@PathVariable String authority) { logger.debug("Request to get users by role : {}", authority); @@ -142,6 +179,10 @@ public ResponseEntity> getAllUsersByRole(@PathVariable Stri return ResponseEntity.ok(usersWithRole); } + @EndpointDescription( + en = "Delete a user", + nl = "Gebruiker verwijderen" + ) @Deprecated(since = "Since 13.20.0", forRemoval = true) @DeleteMapping("/v1/users/{userId}") public ResponseEntity deleteUser(@PathVariable String userId) { @@ -151,6 +192,10 @@ public ResponseEntity deleteUser(@PathVariable String userId) { return ResponseEntity.ok().headers(headers).build(); } + @EndpointDescription( + en = "Resend the verification email to a user", + nl = "Verificatie-e-mail opnieuw naar een gebruiker sturen" + ) @PostMapping("/v1/users/send-verification-email/{userId}") public ResponseEntity resendVerificationEmail(@PathVariable String userId) { logger.debug("Request to resend verification email to user : {}", userId); @@ -158,6 +203,10 @@ public ResponseEntity resendVerificationEmail(@PathVariable String userId) return success ? ResponseEntity.ok().build() : ResponseEntity.badRequest().build(); } + @EndpointDescription( + en = "Get current user settings", + nl = "Instellingen van huidige gebruiker ophalen" + ) @GetMapping("/v1/user/settings") public ResponseEntity getCurrentUserSettings() throws JsonProcessingException { logger.debug("Request to get current user settings"); @@ -170,6 +219,10 @@ public ResponseEntity getCurrentUserSettings() throws JsonProcessingExce return ResponseEntity.ok(objectMapper.writeValueAsString(settings)); } + @EndpointDescription( + en = "Save current user settings", + nl = "Instellingen van huidige gebruiker opslaan" + ) @PutMapping("/v1/user/settings") public ResponseEntity saveCurrentUserSettings(@RequestBody String settings) { logger.debug("Request to create settings for current user"); @@ -184,6 +237,10 @@ public ResponseEntity saveCurrentUserSettings(@RequestBody String settin return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Get current user teams", + nl = "Teams van huidige gebruiker ophalen" + ) @GetMapping("/v1/user/team") public ResponseEntity> getCurrentUserTeams() { List teams = userManagementService.getCurrentUserTeams().stream() diff --git a/backend/core/src/main/java/com/ritense/valtimo/web/rest/VersionResource.java b/backend/core/src/main/java/com/ritense/valtimo/web/rest/VersionResource.java index 4df34e48f8..19d8f0d5a5 100644 --- a/backend/core/src/main/java/com/ritense/valtimo/web/rest/VersionResource.java +++ b/backend/core/src/main/java/com/ritense/valtimo/web/rest/VersionResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -30,6 +31,10 @@ @RequestMapping(value = "/api", produces = APPLICATION_JSON_UTF8_VALUE) public class VersionResource { + @EndpointDescription( + en = "Get the Valtimo version", + nl = "Valtimo-versie ophalen" + ) @GetMapping("/v1/valtimo/version") public ResponseEntity> getValtimoVersion() { String title = ""; diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/web/rest/DecisionManagementResource.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/web/rest/DecisionManagementResource.kt index 756395e54c..d6be469313 100644 --- a/backend/core/src/main/kotlin/com/ritense/valtimo/web/rest/DecisionManagementResource.kt +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/web/rest/DecisionManagementResource.kt @@ -20,6 +20,7 @@ import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthor import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.decision.OperatonDecisionService import com.ritense.valtimo.service.OperatonProcessService import com.ritense.valtimo.web.rest.dto.DecisionDefinitionResponseDto @@ -47,6 +48,10 @@ class DecisionManagementResource( private val operatonDecisionService: OperatonDecisionService, ) { + @EndpointDescription( + en = "List decision definitions for a case definition", + nl = "Beslisdefinities voor een dossierdefinitie ophalen", + ) @GetMapping( value = ["/v1/decision-definition"], produces = [MediaType.APPLICATION_JSON_VALUE] @@ -61,6 +66,10 @@ class DecisionManagementResource( }) } + @EndpointDescription( + en = "List the decision definitions available for a case definition version", + nl = "Beslissingsdefinities voor een zaakdefinitieversie ophalen", + ) @GetMapping( value = ["/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/decision-definition"], produces = [MediaType.APPLICATION_JSON_VALUE] @@ -79,6 +88,10 @@ class DecisionManagementResource( }) } + @EndpointDescription( + en = "Deploy a decision definition", + nl = "Beslisdefinitie uitrollen", + ) @PostMapping( value = ["/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/decision-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -112,6 +125,10 @@ class DecisionManagementResource( ) } + @EndpointDescription( + en = "Delete a decision definition", + nl = "Beslisdefinitie verwijderen", + ) @DeleteMapping( value = ["/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/decision-definition/{decisionDefinitionKey}"], ) diff --git a/backend/core/src/main/resources/config/liquibase/13-28-0/13-28-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-28-0/13-28-0-master.xml index a14e1e9cf8..335e6a01a8 100644 --- a/backend/core/src/main/resources/config/liquibase/13-28-0/13-28-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-28-0/13-28-0-master.xml @@ -23,5 +23,7 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liqui + + diff --git a/backend/core/src/main/resources/config/liquibase/13-28-0/20260504-external-plugin.xml b/backend/core/src/main/resources/config/liquibase/13-28-0/20260504-external-plugin.xml new file mode 100644 index 0000000000..c9a541195d --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-28-0/20260504-external-plugin.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-28-0/20260617-external-plugin-event-queue.xml b/backend/core/src/main/resources/config/liquibase/13-28-0/20260617-external-plugin-event-queue.xml new file mode 100644 index 0000000000..31d97ac569 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-28-0/20260617-external-plugin-event-queue.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml index 5bfcdce345..f59d24be0a 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml @@ -23,5 +23,15 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liqui + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260622-add-case-external-plugin-tab.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260622-add-case-external-plugin-tab.xml new file mode 100644 index 0000000000..656d7f96a9 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260622-add-case-external-plugin-tab.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260625-create-admin-settings-menu.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260625-create-admin-settings-menu.xml new file mode 100644 index 0000000000..1beed65117 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260625-create-admin-settings-menu.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260706-add-external-plugin-task-form-process-link.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260706-add-external-plugin-task-form-process-link.xml new file mode 100644 index 0000000000..f6800f049f --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260706-add-external-plugin-task-form-process-link.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260708-external-plugin-host-kind.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260708-external-plugin-host-kind.xml new file mode 100644 index 0000000000..d4fc4a6849 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260708-external-plugin-host-kind.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml new file mode 100644 index 0000000000..18a5a60444 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml new file mode 100644 index 0000000000..e0667c245e --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml new file mode 100644 index 0000000000..55c90095b3 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE process_link + SET plugin_definition_key = epd.plugin_id, + plugin_definition_version = epd.version + FROM external_plugin_configuration epc + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + WHERE process_link.external_plugin_config_id = epc.id + AND process_link.process_link_type = 'external_plugin' + + + + + + + + + + + + UPDATE process_link pl + INNER JOIN external_plugin_configuration epc ON pl.external_plugin_config_id = epc.id + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + SET pl.plugin_definition_key = epd.plugin_id, + pl.plugin_definition_version = epd.version + WHERE pl.process_link_type = 'external_plugin' + + + + + + + + + + + + + UPDATE process_link + SET plugin_definition_key = epd.plugin_id, + plugin_definition_version = epd.version + FROM external_plugin_configuration epc + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + WHERE process_link.external_plugin_task_form_config_id = epc.id + AND process_link.process_link_type = 'external_plugin_task_form' + + + + + + + + + + + + UPDATE process_link pl + INNER JOIN external_plugin_configuration epc ON pl.external_plugin_task_form_config_id = epc.id + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + SET pl.plugin_definition_key = epd.plugin_id, + pl.plugin_definition_version = epd.version + WHERE pl.process_link_type = 'external_plugin_task_form' + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml new file mode 100644 index 0000000000..3d3dcbcf12 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml new file mode 100644 index 0000000000..7e51f0416f --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260806-external-plugin-security-hardening.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260806-external-plugin-security-hardening.xml new file mode 100644 index 0000000000..afa45ff335 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260806-external-plugin-security-hardening.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/AdminDashboardResource.kt b/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/AdminDashboardResource.kt index 4df4affb5c..17cf01ff28 100644 --- a/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/AdminDashboardResource.kt +++ b/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/AdminDashboardResource.kt @@ -28,6 +28,7 @@ import com.ritense.dashboard.web.rest.dto.WidgetConfigurationCreateRequestDto import com.ritense.dashboard.web.rest.dto.WidgetConfigurationUpdateRequestDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -47,6 +48,10 @@ class AdminDashboardResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List dashboards", + nl = "Dashboards ophalen", + ) @GetMapping("/v1/dashboard") fun getDashboards(): ResponseEntity> { val dashboardResponseDtos = dashboardService.getDashboards() @@ -55,6 +60,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get dashboard by key", + nl = "Dashboard ophalen op sleutel", + ) @GetMapping("/v1/dashboard/{dashboardKey}") fun getDashboard( @PathVariable(name = "dashboardKey") dashboardKey: String @@ -64,6 +73,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create dashboard", + nl = "Dashboard aanmaken", + ) @PostMapping("/v1/dashboard") fun createDashboard( @Valid @RequestBody dashboardDto: DashboardCreateRequestDto @@ -77,6 +90,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update dashboards order", + nl = "Volgorde van dashboards bijwerken", + ) @PutMapping("/v1/dashboard") fun editDashboards( @Valid @RequestBody dashboardUpdateRequestDtos: List @@ -87,6 +104,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete dashboard", + nl = "Dashboard verwijderen", + ) @DeleteMapping("/v1/dashboard/{dashboardKey}") fun deleteDashboard( @PathVariable(name = "dashboardKey") dashboardKey: String @@ -96,6 +117,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update dashboard", + nl = "Dashboard bijwerken", + ) @PutMapping("/v1/dashboard/{dashboardKey}") fun editDashboard( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -110,6 +135,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List widget configurations for dashboard", + nl = "Widgetconfiguraties van dashboard ophalen", + ) @GetMapping("/v1/dashboard/{dashboardKey}/widget-configuration") fun getWidgetConfigurations( @PathVariable(name = "dashboardKey") dashboardKey: String @@ -120,6 +149,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create dashboard widget configuration", + nl = "Dashboardwidgetconfiguratie aanmaken", + ) @PostMapping("/v1/dashboard/{dashboardKey}/widget-configuration") fun createWidgetConfiguration( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -138,6 +171,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update dashboard widget configurations order", + nl = "Volgorde van dashboardwidgetconfiguraties bijwerken", + ) @PutMapping("/v1/dashboard/{dashboardKey}/widget-configuration") fun editWidgetConfigurations( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -149,6 +186,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update dashboard widget configuration", + nl = "Dashboardwidgetconfiguratie bijwerken", + ) @PutMapping("/v1/dashboard/{dashboardKey}/widget-configuration/{widgetKey}") fun editWidgetConfiguration( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -161,6 +202,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get dashboard widget configuration by key", + nl = "Dashboardwidgetconfiguratie ophalen op sleutel", + ) @GetMapping("/v1/dashboard/{dashboardKey}/widget-configuration/{widgetKey}") fun getWidgetConfigurations( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -171,6 +216,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete dashboard widget configuration", + nl = "Dashboardwidgetconfiguratie verwijderen", + ) @DeleteMapping("/v1/dashboard/{dashboardKey}/widget-configuration/{widgetKey}") fun deleteWidgetConfiguration( @PathVariable(name = "dashboardKey") dashboardKey: String, @@ -181,6 +230,10 @@ class AdminDashboardResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List dashboard widget data sources", + nl = "Dashboardwidget-gegevensbronnen ophalen", + ) @GetMapping("/v1/dashboard/widget-data-sources") fun getWidgetDataSources(): ResponseEntity> { return ResponseEntity.ok(dashboardService.getWidgetDataSources()) diff --git a/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/DashboardResource.kt b/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/DashboardResource.kt index 9b53be835d..1af06e1e2b 100644 --- a/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/DashboardResource.kt +++ b/backend/dashboard/src/main/kotlin/com/ritense/dashboard/web/rest/DashboardResource.kt @@ -22,6 +22,7 @@ import com.ritense.dashboard.web.rest.dto.DashboardWidgetDataResultDto import com.ritense.dashboard.web.rest.dto.DashboardWithWidgetsResponseDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping @@ -36,6 +37,10 @@ class DashboardResource( private val dashboardDataService: DashboardDataService, ) { + @EndpointDescription( + en = "List dashboards with dashboard widgets", + nl = "Dashboards met dashboardwidgets ophalen", + ) @GetMapping("/v1/dashboard") fun getDashboards(): ResponseEntity> { val dashboardResponseDtos = dashboardService.getDashboards() @@ -43,6 +48,10 @@ class DashboardResource( return ResponseEntity.ok(dashboardResponseDtos) } + @EndpointDescription( + en = "Get dashboard widget data", + nl = "Widgetgegevens van dashboard ophalen", + ) @GetMapping("/v1/dashboard/{dashboardKey}/data") fun getDashboardData(@PathVariable dashboardKey: String): ResponseEntity> { val data = dashboardDataService.getWidgetDataForDashboard(dashboardKey) diff --git a/backend/data-provider/src/main/kotlin/com/ritense/dataprovider/web/rest/DataProviderResource.kt b/backend/data-provider/src/main/kotlin/com/ritense/dataprovider/web/rest/DataProviderResource.kt index 07317c666d..a3fcc8c724 100644 --- a/backend/data-provider/src/main/kotlin/com/ritense/dataprovider/web/rest/DataProviderResource.kt +++ b/backend/data-provider/src/main/kotlin/com/ritense/dataprovider/web/rest/DataProviderResource.kt @@ -19,6 +19,7 @@ package com.ritense.dataprovider.web.rest import com.ritense.dataprovider.service.DataProviderService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -36,6 +37,10 @@ class DataProviderResource( private val dataProviderService: DataProviderService, ) { + @EndpointDescription( + en = "List data provider names by category", + nl = "Gegevensbronnamen ophalen op categorie", + ) @GetMapping("/v1/data/{category}/provider") fun getProviderNames( @PathVariable category: String, @@ -43,6 +48,10 @@ class DataProviderResource( return ResponseEntity.ok(dataProviderService.getProviderNames(category)) } + @EndpointDescription( + en = "List all data by category", + nl = "Alle gegevens ophalen op categorie", + ) @GetMapping("/v1/data/{category}/all") fun getAll( @PathVariable category: String, @@ -52,6 +61,10 @@ class DataProviderResource( return ResponseEntity.ok(dataProviderService.getAllData(category, provider, query)) } + @EndpointDescription( + en = "Get data by category", + nl = "Gegevens ophalen op categorie", + ) @GetMapping("/v1/data/{category}") fun getData( @PathVariable category: String, @@ -61,6 +74,10 @@ class DataProviderResource( return ResponseEntity.ok(dataProviderService.getData(category, provider, query)) } + @EndpointDescription( + en = "Post data by category", + nl = "Gegevens versturen op categorie", + ) @PostMapping("/v1/data/{category}") fun postData( @PathVariable category: String, @@ -72,6 +89,10 @@ class DataProviderResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Delete data by category", + nl = "Gegevens verwijderen op categorie", + ) @DeleteMapping("/v1/data/{category}") fun deleteData( @PathVariable category: String, diff --git a/backend/dependencies/valtimo-dependencies/build.gradle b/backend/dependencies/valtimo-dependencies/build.gradle index 663b4879ab..de1382ee5c 100644 --- a/backend/dependencies/valtimo-dependencies/build.gradle +++ b/backend/dependencies/valtimo-dependencies/build.gradle @@ -28,6 +28,7 @@ dependencies { api(project(":backend:dashboard")) api(project(":backend:data-provider")) api(project(":backend:exporter")) + api(project(":backend:external-plugin")) api(project(":backend:form")) api(project(":backend:form-flow")) api(project(":backend:form-view-model")) diff --git a/backend/docker-resources/docker-compose-base-test-mysql.yml b/backend/docker-resources/docker-compose-base-test-mysql.yml index 91f89ef9ce..d4bb9e621b 100644 --- a/backend/docker-resources/docker-compose-base-test-mysql.yml +++ b/backend/docker-resources/docker-compose-base-test-mysql.yml @@ -74,5 +74,6 @@ services: # url: jdbc:mysql://localhost:3365/admin-settings-test # url: jdbc:mysql://localhost:3366/documenten-api-preview-test # url: jdbc:mysql://localhost:3367/aws-test +# url: jdbc:mysql://localhost:3368/external-plugin-test # url: localhost:55672 <--outbox rabbitmq # url: localhost:55673 <--inbox rabbitmq diff --git a/backend/docker-resources/docker-compose-base-test-postgresql.yml b/backend/docker-resources/docker-compose-base-test-postgresql.yml index d0f15e0458..c2de8c76bf 100644 --- a/backend/docker-resources/docker-compose-base-test-postgresql.yml +++ b/backend/docker-resources/docker-compose-base-test-postgresql.yml @@ -65,5 +65,6 @@ services: # url: jdbc:postgresql://localhost:3365/admin-settings-test # url: jdbc:postgresql://localhost:3366/documenten-api-preview-test # url: jdbc:postgresql://localhost:3367/aws-test +# url: jdbc:postgresql://localhost:3368/external-plugin-test # url: localhost:55672 <--rabbitmq # url: localhost:55673 <--rabbitmq diff --git a/backend/exact-plugin/src/main/kotlin/com/ritense/exact/web/rest/ExactResource.kt b/backend/exact-plugin/src/main/kotlin/com/ritense/exact/web/rest/ExactResource.kt index b6944bc6aa..4f36e311e0 100644 --- a/backend/exact-plugin/src/main/kotlin/com/ritense/exact/web/rest/ExactResource.kt +++ b/backend/exact-plugin/src/main/kotlin/com/ritense/exact/web/rest/ExactResource.kt @@ -5,6 +5,7 @@ import com.ritense.exact.service.request.ExactExchangeRequest import com.ritense.exact.service.response.ExactExchangeResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -18,6 +19,10 @@ class ExactResource( val exactService: ExactService ) { + @EndpointDescription( + en = "Exchange an Exact authorization code", + nl = "Exact-autorisatiecode uitwisselen", + ) @PostMapping("/v1/plugin/exact/exchange") fun exchange(@Valid @RequestBody request: ExactExchangeRequest): ExactExchangeResponse { return exactService.exchange(request) diff --git a/backend/external-plugin/build.gradle b/backend/external-plugin/build.gradle new file mode 100644 index 0000000000..7247c73c7c --- /dev/null +++ b/backend/external-plugin/build.gradle @@ -0,0 +1,112 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +dockerCompose { + projectName = "externalplugin" + + securityTesting { + isRequiredBy(project.tasks.securityTesting) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-postgresql.yml", "docker-compose-override-postgresql.yml") + } + + integrationTestingPostgresql { + isRequiredBy(project.tasks.integrationTestingPostgresql) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-postgresql.yml", "docker-compose-override-postgresql.yml") + } + + integrationTestingMysql { + isRequiredBy(project.tasks.integrationTestingMysql) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-mysql.yml", "docker-compose-override-mysql.yml") + } +} + +dependencies { + implementation project(":backend:process-link") + implementation project(":backend:process-document") + implementation project(":backend:value-resolver") + implementation project(":backend:core") + implementation project(":backend:contract") + implementation project(":backend:web") + implementation project(":backend:logging") + implementation project(":backend:plugin") + implementation project(":backend:authorization") + implementation project(":backend:case") + + implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.boot:spring-boot-starter" + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-validation" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server" + + implementation "io.github.oshai:kotlin-logging:${kotlinLoggingVersion}" + implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + implementation "com.networknt:json-schema-validator:1.5.6" + implementation "net.javacrumbs.shedlock:shedlock-spring:${shedlockVersion}" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.mockito.kotlin:mockito-kotlin:${mockitoKotlinVersion}" + testImplementation "org.jetbrains.kotlin:kotlin-test" + testImplementation project(":backend:test-utils-common") + testImplementation project(":backend:importer") + testImplementation project(":backend:plugin-valtimo") + + // Modules whose controllers EndpointDescriptionCoverageTest scans — it requires every endpoint + // to carry an @EndpointDescription, so those controllers must be on the test classpath + // (:backend:case is already a compile dependency above) + testImplementation project(":backend:process-document") + testImplementation project(":backend:form") + testImplementation project(":backend:form-flow") + testImplementation project(":backend:dashboard") + testImplementation project(":backend:localization") + testImplementation project(":backend:admin-settings") + testImplementation project(":backend:building-block") + testImplementation project(":backend:iko") + testImplementation project(":backend:zgw:zaken-api") + testImplementation project(":backend:zgw:documenten-api") + testImplementation project(":backend:zgw:catalogi-api") + testImplementation project(":backend:zgw:notificaties-api") + testImplementation project(":backend:zgw:object-management") + testImplementation project(":backend:zgw:zaakdetails") + + // Controller-bearing modules that ship in the application but are not compile dependencies of + // external-plugin — on the test classpath so the coverage test enforces descriptions on every + // runtime endpoint, not only those reachable from this module's own dependencies + testImplementation project(":backend:notes") + testImplementation project(":backend:team") + testImplementation project(":backend:milestones") + testImplementation project(":backend:mail:mandrill") + testImplementation project(":backend:process-link-url") + testImplementation project(":backend:form-view-model") + testImplementation project(":backend:zgw:documenten-api-preview") + testImplementation project(":backend:exact-plugin") + testImplementation project(":backend:keycloak-iam") + // Supplies the ResourceService that DocumentAutoConfiguration (pulled in via :backend:case) + // requires in the full IT context; external-plugin never touches file storage, so the local + // impl is used rather than s3-resource (which would force AWS/S3 config for no functional reason). + testImplementation project(":backend:resource:local-resource") + testImplementation project(":backend:case-opensearch") + + jar { + enabled = true + manifest { + attributes("Implementation-Title": "Ritense External Plugin module") + attributes("Implementation-Version": projectVersion) + } + } +} + +apply from: "gradle/publishing.gradle" diff --git a/backend/external-plugin/docker-compose-override-mysql.yml b/backend/external-plugin/docker-compose-override-mysql.yml new file mode 100644 index 0000000000..79d3abbdc2 --- /dev/null +++ b/backend/external-plugin/docker-compose-override-mysql.yml @@ -0,0 +1,6 @@ +services: + db: + ports: + - "3368:3306" + environment: + - MYSQL_DATABASE=external-plugin-test diff --git a/backend/external-plugin/docker-compose-override-postgresql.yml b/backend/external-plugin/docker-compose-override-postgresql.yml new file mode 100644 index 0000000000..9c6f071a70 --- /dev/null +++ b/backend/external-plugin/docker-compose-override-postgresql.yml @@ -0,0 +1,6 @@ +services: + db: + ports: + - "3368:5432" + environment: + - POSTGRES_DB=external-plugin-test diff --git a/backend/external-plugin/gradle/publishing.gradle b/backend/external-plugin/gradle/publishing.gradle new file mode 100644 index 0000000000..6fad5b6e52 --- /dev/null +++ b/backend/external-plugin/gradle/publishing.gradle @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pluginManager.withPlugin('maven-publish') { + publishing { + publications { + maven(MavenPublication) { + pom { + name = 'External Plugin module' + description = 'GZAC integration with the external plugin host: host registration, plugin discovery, configuration management and process-link execution.' + developers { + developer { + id = "team-valtimo" + name = "Team Valtimo" + email = "team-valtimo@ritense.com" + } + } + } + } + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt new file mode 100644 index 0000000000..4499c12579 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -0,0 +1,595 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.autoconfigure + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.compatibility.DefaultGzacVersionProvider +import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker +import com.ritense.externalplugin.compatibility.GzacVersionProvider +import com.ritense.externalplugin.compatibility.PluginPackageInspector +import com.ritense.externalplugin.preview.ExternalPluginImportPreviewContributor +import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper +import com.ritense.externalplugin.processlink.ExternalPluginServiceTaskStartListener +import com.ritense.externalplugin.processlink.ExternalPluginSupportedProcessLinkTypeHandler +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormProcessLinkActivityHandler +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormProcessLinkMapper +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormSubmissionService +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormSupportedProcessLinkTypeHandler +import com.ritense.externalplugin.processlink.web.ExternalPluginTaskFormSubmissionResource +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedCapabilityRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.externalplugin.security.ExternalPluginCallbackHttpSecurityConfigurer +import com.ritense.externalplugin.security.ExternalPluginEndpointAllowlistFilter +import com.ritense.externalplugin.security.ExternalPluginHttpSecurityConfigurer +import com.ritense.externalplugin.security.ExternalPluginServiceTokenAuthenticator +import com.ritense.externalplugin.security.ExternalPluginServiceTokenFilter +import com.ritense.externalplugin.security.ExternalPluginServiceTokenKeyProvider +import com.ritense.externalplugin.security.ExternalPluginUserTokenAuthenticator +import com.ritense.externalplugin.security.ExternalPluginUserTokenFilter +import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider +import com.ritense.externalplugin.service.EndpointDescriptionService +import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver +import com.ritense.externalplugin.service.ExternalPluginCaseTabResolverImpl +import com.ritense.externalplugin.service.ExternalPluginCaseWidgetResolverImpl +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginDiscoveryJob +import com.ritense.externalplugin.service.ExternalPluginDiscoveryService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.externalplugin.service.ExternalPluginHostUsageResolver +import com.ritense.externalplugin.service.ExternalPluginMenuPageService +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService +import com.ritense.externalplugin.service.ExternalPluginUserTokenService +import com.ritense.externalplugin.service.PluginPropertyEncryptor +import com.ritense.externalplugin.web.rest.ExternalPluginHostOriginsResource +import com.ritense.externalplugin.web.rest.ExternalPluginManagementResource +import com.ritense.externalplugin.web.rest.ExternalPluginMenuPageResource +import com.ritense.externalplugin.web.rest.ExternalPluginUserTokenIntrospectionResource +import com.ritense.externalplugin.web.rest.ExternalPluginUserTokenResource +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.service.PluginActionResultHandler +import com.ritense.plugin.service.ProcessDefinitionUsageMetaResolver +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import com.ritense.valueresolver.ValueResolverService +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.convert.DurationStyle +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Bean +import org.springframework.core.annotation.Order +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.client.RestTemplate +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping + +// @EnableScheduling stays even though this is an @AutoConfiguration: the discovery job's @Scheduled +// method needs a ScheduledAnnotationBeanPostProcessor, which no other module is guaranteed to +// contribute (same pattern as DocumentOpenSearchAutoConfiguration). +@AutoConfiguration +@EnableScheduling +@EntityScan("com.ritense.externalplugin.domain") +@EnableJpaRepositories("com.ritense.externalplugin.repository") +class ExternalPluginAutoConfiguration { + + /** + * RestTemplate for all GZAC→host calls. Timeouts are mandatory: host calls run on request + * threads and in the discovery cycle, so an unresponsive host must fail fast instead of + * hanging a thread (or, worse, a transaction) indefinitely. + */ + @Bean("externalPluginRestTemplate") + @ConditionalOnMissingBean(name = ["externalPluginRestTemplate"]) + fun externalPluginRestTemplate( + builder: RestTemplateBuilder, + @Value("\${valtimo.external-plugin.connect-timeout:PT2S}") connectTimeout: String, + @Value("\${valtimo.external-plugin.read-timeout:PT10S}") readTimeout: String, + ): RestTemplate = builder + .connectTimeout(DurationStyle.detectAndParse(connectTimeout)) + .readTimeout(DurationStyle.detectAndParse(readTimeout)) + .build() + + @Bean + @ConditionalOnMissingBean(PluginPropertyEncryptor::class) + fun pluginPropertyEncryptor(encryptionService: EncryptionService) = + PluginPropertyEncryptor(encryptionService) + + @Bean + @ConditionalOnMissingBean(ExternalPluginHostClient::class) + fun externalPluginHostClient( + @org.springframework.beans.factory.annotation.Qualifier("externalPluginRestTemplate") restTemplate: RestTemplate, + objectMapper: ObjectMapper, + ) = ExternalPluginHostClient(restTemplate, objectMapper) + + @Bean + @ConditionalOnMissingBean(ExternalPluginHostUsageResolver::class) + fun externalPluginHostUsageResolver( + definitionRepository: ExternalPluginDefinitionRepository, + configurationRepository: ExternalPluginConfigurationRepository, + processLinkRepository: ExternalPluginProcessLinkRepository, + taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + processDefinitionUsageMetaResolver: ProcessDefinitionUsageMetaResolver, + caseExternalPluginTabService: java.util.Optional, + caseExternalPluginWidgetService: java.util.Optional, + buildingBlockMappingUsageFinder: java.util.Optional, + ) = ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + processDefinitionUsageMetaResolver, + caseExternalPluginTabService, + caseExternalPluginWidgetService, + buildingBlockMappingUsageFinder, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginHostService::class) + fun externalPluginHostService( + hostRepository: ExternalPluginHostRepository, + definitionRepository: ExternalPluginDefinitionRepository, + configurationRepository: ExternalPluginConfigurationRepository, + grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + grantedEventRepository: ExternalPluginGrantedEventRepository, + grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository, + encryptionService: EncryptionService, + hostClient: ExternalPluginHostClient, + hostUsageResolver: ExternalPluginHostUsageResolver, + ) = ExternalPluginHostService( + hostRepository, + definitionRepository, + configurationRepository, + grantedEndpointRepository, + grantedEventRepository, + grantedCapabilityRepository, + encryptionService, + hostClient, + hostUsageResolver, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginDefinitionService::class) + fun externalPluginDefinitionService(definitionRepository: ExternalPluginDefinitionRepository) = + ExternalPluginDefinitionService(definitionRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginBundleUrlResolver::class) + fun externalPluginBundleUrlResolver( + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ) = ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginCaseTabResolverImpl::class) + fun externalPluginCaseTabResolver( + bundleUrlResolver: ExternalPluginBundleUrlResolver, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ) = ExternalPluginCaseTabResolverImpl(bundleUrlResolver, configurationRepository, definitionRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginCaseWidgetResolverImpl::class) + fun externalPluginCaseWidgetResolver( + bundleUrlResolver: ExternalPluginBundleUrlResolver, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ) = ExternalPluginCaseWidgetResolverImpl(bundleUrlResolver, configurationRepository, definitionRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginMenuPageService::class) + fun externalPluginMenuPageService( + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + bundleUrlResolver: ExternalPluginBundleUrlResolver, + ) = ExternalPluginMenuPageService(configurationRepository, definitionRepository, bundleUrlResolver) + + @Bean + @ConditionalOnMissingBean(ExternalPluginMenuPageResource::class) + fun externalPluginMenuPageResource( + menuPageService: ExternalPluginMenuPageService, + ) = ExternalPluginMenuPageResource(menuPageService) + + @Bean + @ConditionalOnMissingBean(ExternalPluginHostOriginsResource::class) + fun externalPluginHostOriginsResource( + hostService: ExternalPluginHostService, + ) = ExternalPluginHostOriginsResource(hostService) + + @Bean + @ConditionalOnMissingBean(ExternalPluginServiceTokenKeyProvider::class) + fun externalPluginServiceTokenKeyProvider( + @Value("\${valtimo.plugin.encryption-secret}") secret: String, + ) = ExternalPluginServiceTokenKeyProvider(secret) + + // 10-minute default: the discovery poll (60s) re-pushes a fresh token every cycle, so a short + // TTL costs nothing operationally while capping how long a leaked token stays usable. + @Bean + @ConditionalOnMissingBean(ExternalPluginServiceTokenService::class) + fun externalPluginServiceTokenService( + keyProvider: ExternalPluginServiceTokenKeyProvider, + @Value("\${valtimo.external-plugin.service-token.ttl:PT10M}") tokenTtl: String, + ) = ExternalPluginServiceTokenService(keyProvider, DurationStyle.detectAndParse(tokenTtl)) + + @Bean + @ConditionalOnMissingBean(ExternalPluginServiceTokenAuthenticator::class) + fun externalPluginServiceTokenAuthenticator( + configurationRepository: ExternalPluginConfigurationRepository, + ) = ExternalPluginServiceTokenAuthenticator(configurationRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginEndpointAllowlistFilter::class) + fun externalPluginEndpointAllowlistFilter( + grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + ) = ExternalPluginEndpointAllowlistFilter(grantedEndpointRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginServiceTokenFilter::class) + fun externalPluginServiceTokenFilter( + keyProvider: ExternalPluginServiceTokenKeyProvider, + authenticator: ExternalPluginServiceTokenAuthenticator, + ) = ExternalPluginServiceTokenFilter(keyProvider, authenticator) + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenKeyProvider::class) + fun externalPluginUserTokenKeyProvider( + @Value("\${valtimo.plugin.encryption-secret}") secret: String, + ) = ExternalPluginUserTokenKeyProvider(secret) + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenService::class) + fun externalPluginUserTokenService( + keyProvider: ExternalPluginUserTokenKeyProvider, + @Value("\${valtimo.external-plugin.user-token.ttl:PT15M}") tokenTtl: String, + ) = ExternalPluginUserTokenService(keyProvider, DurationStyle.detectAndParse(tokenTtl)) + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenAuthenticator::class) + fun externalPluginUserTokenAuthenticator( + configurationRepository: ExternalPluginConfigurationRepository, + ) = ExternalPluginUserTokenAuthenticator(configurationRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenFilter::class) + fun externalPluginUserTokenFilter( + keyProvider: ExternalPluginUserTokenKeyProvider, + authenticator: ExternalPluginUserTokenAuthenticator, + ) = ExternalPluginUserTokenFilter(keyProvider, authenticator) + + /** + * The three external-plugin filters are exposed as `Filter`-typed beans (so the security + * configurer can insert them at the right position in the Spring Security chain), which means + * Spring Boot would *also* auto-register each of them as a plain servlet filter running on + * every request. These disabled registrations suppress that second, chain-independent + * registration — the filters must only ever run inside the security filter chain. + */ + @Bean + fun externalPluginServiceTokenFilterRegistration( + filter: ExternalPluginServiceTokenFilter, + ): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { isEnabled = false } + + @Bean + fun externalPluginUserTokenFilterRegistration( + filter: ExternalPluginUserTokenFilter, + ): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { isEnabled = false } + + @Bean + fun externalPluginEndpointAllowlistFilterRegistration( + filter: ExternalPluginEndpointAllowlistFilter, + ): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { isEnabled = false } + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenResource::class) + fun externalPluginUserTokenResource( + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + userTokenService: ExternalPluginUserTokenService, + ) = ExternalPluginUserTokenResource( + configurationRepository, + definitionRepository, + grantedEndpointRepository, + userTokenService, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginUserTokenIntrospectionResource::class) + fun externalPluginUserTokenIntrospectionResource( + keyProvider: ExternalPluginUserTokenKeyProvider, + ) = ExternalPluginUserTokenIntrospectionResource(keyProvider) + + @Bean + @Order(450) + @ConditionalOnMissingBean(ExternalPluginCallbackHttpSecurityConfigurer::class) + fun externalPluginCallbackHttpSecurityConfigurer( + serviceTokenFilter: ExternalPluginServiceTokenFilter, + userTokenFilter: ExternalPluginUserTokenFilter, + allowlistFilter: ExternalPluginEndpointAllowlistFilter, + ) = ExternalPluginCallbackHttpSecurityConfigurer(serviceTokenFilter, userTokenFilter, allowlistFilter) + + /** + * The configuration service only needs two fallbacks: + * + * - `defaultEventBrokerExchange` reuses `valtimo.outbox.publisher.rabbitmq.exchange` — applied + * when a host row leaves `event_broker_exchange` null. New hosts almost never override this. + * - `fallbackGzacBaseUrl` only kicks in for legacy host rows that pre-date the + * `gzac_callback_base_url` column. New hosts always carry the URL the admin entered. + * + * Everything else (callback URL, broker URL) is per-host and read off the host row at push + * time. The add-host form fetches sensible pre-fills from `HostDefaultsResource`. + */ + @Bean + @ConditionalOnMissingBean(ExternalPluginConfigurationService::class) + fun externalPluginConfigurationService( + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + hostRepository: ExternalPluginHostRepository, + grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + grantedEventRepository: ExternalPluginGrantedEventRepository, + grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository, + hostClient: ExternalPluginHostClient, + propertyEncryptor: PluginPropertyEncryptor, + encryptionService: EncryptionService, + objectMapper: ObjectMapper, + serviceTokenService: ExternalPluginServiceTokenService, + hostUsageResolver: ExternalPluginHostUsageResolver, + @Value("\${server.port:8080}") serverPort: Int, + @Value("\${valtimo.outbox.publisher.rabbitmq.exchange:valtimo-events}") defaultEventBrokerExchange: String, + ) = ExternalPluginConfigurationService( + configurationRepository, + definitionRepository, + hostRepository, + grantedEndpointRepository, + grantedEventRepository, + grantedCapabilityRepository, + hostClient, + propertyEncryptor, + encryptionService, + objectMapper, + serviceTokenService, + hostUsageResolver, + defaultEventBrokerExchange, + "http://localhost:$serverPort", + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginDiscoveryService::class) + fun externalPluginDiscoveryService( + hostRepository: ExternalPluginHostRepository, + definitionRepository: ExternalPluginDefinitionRepository, + configurationRepository: ExternalPluginConfigurationRepository, + configurationService: ExternalPluginConfigurationService, + hostService: ExternalPluginHostService, + hostClient: ExternalPluginHostClient, + transactionManager: PlatformTransactionManager, + @Value("\${valtimo.external-plugin.polling.failure-threshold:3}") failureThreshold: Int, + ) = ExternalPluginDiscoveryService( + hostRepository, + definitionRepository, + configurationRepository, + configurationService, + hostService, + hostClient, + TransactionTemplate(transactionManager), + failureThreshold, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginDiscoveryJob::class) + fun externalPluginDiscoveryJob(discoveryService: ExternalPluginDiscoveryService) = + ExternalPluginDiscoveryJob(discoveryService) + + @Bean + @ConditionalOnMissingBean(EndpointDescriptionService::class) + fun endpointDescriptionService( + handlerMappings: List, + ) = EndpointDescriptionService(handlerMappings) + + @Bean + @ConditionalOnMissingBean(GzacVersionProvider::class) + fun gzacVersionProvider( + @Value("\${valtimo.external-plugin.gzac-version:}") versionOverride: String, + ): GzacVersionProvider = DefaultGzacVersionProvider( + versionOverride, + DefaultGzacVersionProvider::class.java.`package`?.implementationVersion, + ) + + @Bean + @ConditionalOnMissingBean(GzacCompatibilityChecker::class) + fun gzacCompatibilityChecker(versionProvider: GzacVersionProvider) = + GzacCompatibilityChecker(versionProvider) + + @Bean + @ConditionalOnMissingBean(PluginPackageInspector::class) + fun pluginPackageInspector(objectMapper: ObjectMapper) = PluginPackageInspector(objectMapper) + + @Bean + @ConditionalOnMissingBean(ExternalPluginManagementResource::class) + fun externalPluginManagementResource( + hostService: ExternalPluginHostService, + definitionService: ExternalPluginDefinitionService, + configurationService: ExternalPluginConfigurationService, + hostClient: ExternalPluginHostClient, + endpointDescriptionService: EndpointDescriptionService, + discoveryService: ExternalPluginDiscoveryService, + environment: org.springframework.core.env.Environment, + compatibilityChecker: GzacCompatibilityChecker, + pluginPackageInspector: PluginPackageInspector, + objectMapper: ObjectMapper, + ) = ExternalPluginManagementResource( + hostService, + definitionService, + configurationService, + hostClient, + endpointDescriptionService, + discoveryService, + environment, + compatibilityChecker, + pluginPackageInspector, + objectMapper, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginProcessLinkMapper::class) + fun externalPluginProcessLinkMapper( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + processLinkRepository: ExternalPluginProcessLinkRepository, + ) = ExternalPluginProcessLinkMapper(objectMapper, configurationRepository, definitionRepository, processLinkRepository) + + @Bean + @Order(40) + @ConditionalOnMissingBean(ExternalPluginSupportedProcessLinkTypeHandler::class) + fun externalPluginSupportedProcessLinkTypeHandler() = ExternalPluginSupportedProcessLinkTypeHandler() + + @Bean + @ConditionalOnMissingBean(ExternalPluginTaskFormProcessLinkMapper::class) + fun externalPluginTaskFormProcessLinkMapper( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + ) = ExternalPluginTaskFormProcessLinkMapper(objectMapper, configurationRepository, definitionRepository, taskFormProcessLinkRepository) + + @Bean + @Order(41) + @ConditionalOnMissingBean(ExternalPluginTaskFormSupportedProcessLinkTypeHandler::class) + fun externalPluginTaskFormSupportedProcessLinkTypeHandler() = ExternalPluginTaskFormSupportedProcessLinkTypeHandler() + + @Bean + @ConditionalOnMissingBean(ExternalPluginTaskFormProcessLinkActivityHandler::class) + fun externalPluginTaskFormProcessLinkActivityHandler( + bundleUrlResolver: ExternalPluginBundleUrlResolver, + ) = ExternalPluginTaskFormProcessLinkActivityHandler(bundleUrlResolver) + + @Bean + @ConditionalOnMissingBean(ExternalPluginTaskFormSubmissionService::class) + fun externalPluginTaskFormSubmissionService( + processLinkService: com.ritense.processlink.service.ProcessLinkService, + configurationService: ExternalPluginConfigurationService, + definitionService: ExternalPluginDefinitionService, + hostService: ExternalPluginHostService, + hostClient: ExternalPluginHostClient, + processDocumentService: com.ritense.processdocument.service.ProcessDocumentService, + documentService: com.ritense.document.service.impl.JsonSchemaDocumentService, + operatonTaskService: com.ritense.valtimo.service.OperatonTaskService, + authorizationService: com.ritense.authorization.AuthorizationService, + valueResolverService: ValueResolverService, + objectMapper: ObjectMapper, + ) = ExternalPluginTaskFormSubmissionService( + processLinkService, + configurationService, + definitionService, + hostService, + hostClient, + processDocumentService, + documentService, + operatonTaskService, + authorizationService, + valueResolverService, + objectMapper, + ) + + @Bean + @ConditionalOnMissingBean(ExternalPluginTaskFormSubmissionResource::class) + fun externalPluginTaskFormSubmissionResource( + submissionService: ExternalPluginTaskFormSubmissionService, + ) = ExternalPluginTaskFormSubmissionResource(submissionService) + + @Bean + @ConditionalOnMissingBean(ExternalPluginServiceTaskStartListener::class) + fun externalPluginServiceTaskStartListener( + processLinkRepository: ExternalPluginProcessLinkRepository, + configurationService: ExternalPluginConfigurationService, + definitionService: ExternalPluginDefinitionService, + hostService: ExternalPluginHostService, + hostClient: ExternalPluginHostClient, + valueResolverService: ValueResolverService, + objectMapper: ObjectMapper, + pluginActionResultHandler: PluginActionResultHandler, + buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, + ) = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + buildingBlockPluginConfigurationResolver, + ) + + @Bean + @Order(430) + @ConditionalOnMissingBean(ExternalPluginHttpSecurityConfigurer::class) + fun externalPluginHttpSecurityConfigurer() = ExternalPluginHttpSecurityConfigurer() + + @Bean + @ConditionalOnMissingBean(ExternalPluginImportPreviewContributor::class) + fun externalPluginImportPreviewContributor( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ): ImportPreviewContributor = + ExternalPluginImportPreviewContributor(objectMapper, configurationRepository, definitionRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginConfigurationMappingResolver::class) + fun externalPluginConfigurationMappingResolver( + processLinkRepository: ExternalPluginProcessLinkRepository, + taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + configurationRepository: ExternalPluginConfigurationRepository, + caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + caseTabRepository: CaseTabRepository, + caseExternalPluginWidgetService: com.ritense.case_.service.CaseExternalPluginWidgetService, + processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + caseDefinitionChecker: CaseDefinitionChecker, + applicationEventPublisher: ApplicationEventPublisher, + ): PluginConfigurationMappingResolver = ExternalPluginConfigurationMappingResolver( + processLinkRepository, + taskFormProcessLinkRepository, + configurationRepository, + caseExternalPluginTabRepository, + caseTabRepository, + caseExternalPluginWidgetService, + processDefinitionCaseDefinitionService, + caseDefinitionChecker, + applicationEventPublisher, + ) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClient.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClient.kt new file mode 100644 index 0000000000..0381cc2395 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClient.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.client + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.EventQueueMode +import com.ritense.externalplugin.security.ExternalPluginHmacSigner +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.HttpHeaders +import org.springframework.stereotype.Component +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.RequestEntity +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.HttpServerErrorException +import org.springframework.web.client.ResourceAccessException +import org.springframework.core.io.ByteArrayResource +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate +import java.net.URI +import java.time.Instant + +@Component +@SkipComponentScan +class ExternalPluginHostClient( + private val restTemplate: RestTemplate, + private val objectMapper: ObjectMapper, +) { + + fun health(baseUrl: String): Boolean = try { + val uri = buildUri(baseUrl, "/health") + val request = RequestEntity(HttpMethod.GET, uri) + restTemplate.exchange(request, JsonNode::class.java).statusCode.is2xxSuccessful + } catch (_: ResourceAccessException) { + false + } catch (_: HttpClientErrorException) { + false + } catch (_: HttpServerErrorException) { + false + } + + fun listPlugins(baseUrl: String, adminToken: String): List { + val path = "/api/host/plugins" + val uri = buildUri(baseUrl, path) + val headers = hmacHeaders(adminToken, HttpMethod.GET.name(), path, EMPTY_BODY) + val request = RequestEntity(headers, HttpMethod.GET, uri) + val response = restTemplate.exchange(request, JsonNode::class.java).body + ?: return emptyList() + return when { + response.isArray -> response.toList() + response.has("plugins") && response.get("plugins").isArray -> response.get("plugins").toList() + else -> emptyList() + } + } + + fun pushConfiguration( + baseUrl: String, + adminToken: String, + configId: String, + pluginId: String, + pluginVersion: String, + properties: ObjectNode, + serviceToken: String, + gzacBaseUrl: String, + /** + * The package content hash GZAC pinned at discovery. The host verifies its loaded package + * still matches before accepting the push (409 otherwise), so a config and its fresh + * service token can never reach plugin code that differs from what the admin accepted. + */ + expectedContentHash: String? = null, + /** The CloudEvent types the admin granted. The host uses this list — not the manifest. */ + eventSubscriptions: List, + grantedCapabilities: List = emptyList(), + /** + * The GZAC endpoints the admin granted, as method/Ant-pattern pairs. The host enforces this + * list on every `gzac_api` call, so the allowlist holds even if GZAC-side token scoping + * were to regress. + */ + grantedEndpoints: List> = emptyList(), + eventBrokerUrl: String?, + eventBrokerExchange: String, + eventBrokerExchangeType: String, + /** Per-host queue declaration mode the plugin-host should use for this broker connection. */ + eventQueueMode: EventQueueMode = EventQueueMode.LIVE, + /** Queue inactivity TTL in ms; only meaningful when [eventQueueMode] is DURABLE. */ + eventQueueTtlMs: Long? = null, + ): Boolean = try { + val path = "/api/host/configurations/$configId" + val uri = buildUri(baseUrl, path) + val body = objectMapper.createObjectNode().apply { + put("pluginId", pluginId) + put("pluginVersion", pluginVersion) + set("properties", properties) + put("serviceToken", serviceToken) + put("gzacBaseUrl", gzacBaseUrl) + if (!expectedContentHash.isNullOrBlank()) put("expectedContentHash", expectedContentHash) + // Authoritative subscription list — replaces whatever the manifest declares. + set("eventSubscriptions", objectMapper.createArrayNode().apply { + eventSubscriptions.forEach { add(it) } + }) + set("grantedCapabilities", objectMapper.createArrayNode().apply { + grantedCapabilities.forEach { add(it) } + }) + set("grantedEndpoints", objectMapper.createArrayNode().apply { + grantedEndpoints.forEach { (method, pattern) -> + addObject().put("method", method).put("pattern", pattern) + } + }) + // The host learns this GZAC instance's broker from the push (it never configures one + // itself). Omitted when no broker is configured — events are then disabled for the config. + if (!eventBrokerUrl.isNullOrBlank()) { + set("eventBroker", objectMapper.createObjectNode().apply { + put("amqpUrl", eventBrokerUrl) + put("exchange", eventBrokerExchange) + put("exchangeType", eventBrokerExchangeType) + put("queueMode", eventQueueMode.name.lowercase()) + if (eventQueueTtlMs != null) put("queueTtlMs", eventQueueTtlMs) + }) + } + } + // Sign and send the exact same bytes: the host's HMAC check binds this body, so the service + // token and broker credentials it carries cannot be replayed or altered in flight. + val bodyBytes = objectMapper.writeValueAsBytes(body) + val headers = hmacHeaders(adminToken, HttpMethod.POST.name(), path, bodyBytes).apply { + contentType = MediaType.APPLICATION_JSON + } + val request = RequestEntity(bodyBytes, headers, HttpMethod.POST, uri) + restTemplate.exchange(request, JsonNode::class.java).statusCode.is2xxSuccessful + } catch (e: Exception) { + logger.warn(e) { "Failed to push configuration $configId for plugin '$pluginId@$pluginVersion' to plugin host at $baseUrl" } + false + } + + fun deleteConfiguration(baseUrl: String, adminToken: String, configId: String): Boolean = try { + val path = "/api/host/configurations/$configId" + val uri = buildUri(baseUrl, path) + val headers = hmacHeaders(adminToken, HttpMethod.DELETE.name(), path, EMPTY_BODY) + val request = RequestEntity(headers, HttpMethod.DELETE, uri) + restTemplate.exchange(request, Void::class.java).statusCode.is2xxSuccessful + } catch (e: Exception) { + logger.warn(e) { "Failed to delete configuration $configId from plugin host at $baseUrl" } + false + } + + fun invokeAction( + baseUrl: String, + pluginId: String, + version: String, + actionKey: String, + payload: ObjectNode, + hostSecret: String, + ): ActionResponse { + val path = "/plugins/$pluginId/$version/actions/$actionKey" + val uri = buildUri(baseUrl, path) + val body = objectMapper.writeValueAsBytes(payload) + + val headers = hmacHeaders(hostSecret, HttpMethod.POST.name(), path, body).apply { + contentType = MediaType.APPLICATION_JSON + } + + return exchangeForActionResponse(RequestEntity(body, headers, HttpMethod.POST, uri)) + } + + /** + * Invokes a plugin's `handle_submit` export for a task-form submission (Level 1). Identical + * transport to [invokeAction] — HMAC-signed, service-token-authenticated — but routed to the + * host's submit endpoint. The plugin returns `{status, variables, documentContent}` or + * `{status: "error", …, fieldErrors}`; the caller decides how to complete or reject. + */ + fun invokeSubmit( + baseUrl: String, + pluginId: String, + version: String, + submitKey: String, + payload: ObjectNode, + hostSecret: String, + ): ActionResponse { + val path = "/plugins/$pluginId/$version/submit/$submitKey" + val uri = buildUri(baseUrl, path) + val body = objectMapper.writeValueAsBytes(payload) + + val headers = hmacHeaders(hostSecret, HttpMethod.POST.name(), path, body).apply { + contentType = MediaType.APPLICATION_JSON + } + + return exchangeForActionResponse(RequestEntity(body, headers, HttpMethod.POST, uri)) + } + + /** + * Executes a plugin invocation and maps every failure mode onto an [ActionResponse] so the + * callers' error paths (`actionFailed`, hook rejection) always engage: + * - 4xx/5xx from the host → the host's status plus its parsed error body; + * - connection failure / timeout → a synthetic 503 with a clear "host unreachable" error body. + */ + private fun exchangeForActionResponse(request: RequestEntity<*>): ActionResponse = try { + val response = restTemplate.exchange(request, JsonNode::class.java) + ActionResponse(status = response.statusCode.value(), body = response.body) + } catch (e: HttpClientErrorException) { + ActionResponse(status = e.statusCode.value(), body = parseBody(e.responseBodyAsByteArray)) + } catch (e: HttpServerErrorException) { + logger.warn(e) { "Plugin host at ${request.url} returned ${e.statusCode.value()}" } + ActionResponse(status = e.statusCode.value(), body = parseBody(e.responseBodyAsByteArray)) + } catch (e: ResourceAccessException) { + logger.warn(e) { "Plugin host at ${request.url} is unreachable" } + ActionResponse( + status = 503, + body = objectMapper.createObjectNode().apply { + put("errorCode", HOST_UNREACHABLE_ERROR_CODE) + put("errorMessage", "Plugin host is unreachable: ${e.message}") + }, + ) + } + + fun uploadPlugin( + baseUrl: String, + adminToken: String, + fileName: String, + fileBytes: ByteArray, + /** + * Replace an existing pluginId@version. Only sent after an admin explicitly confirmed the + * overwrite (permission re-review, §11); without it the host refuses a duplicate with 409. + */ + overwrite: Boolean = false, + ): JsonNode { + val path = "/api/host/plugins" + // The query string is deliberately not signature-bound — the host strips it before HMAC + // verification (same convention as getConfigurationLogs). + val uri = buildUri(baseUrl, if (overwrite) "$path?overwrite=true" else path) + val resource = object : ByteArrayResource(fileBytes) { + override fun getFilename(): String = fileName + } + val body = LinkedMultiValueMap().apply { + add("file", resource) + } + // The signature binds the uploaded file bytes, not the multipart envelope (whose boundary + // RestTemplate generates internally and the host cannot reproduce). The host recomputes the + // hash over the same file bytes after parsing the upload. + val headers = hmacHeaders(adminToken, HttpMethod.POST.name(), path, fileBytes).apply { + contentType = MediaType.MULTIPART_FORM_DATA + } + val request = RequestEntity(body, headers, HttpMethod.POST, uri) + return restTemplate.exchange(request, JsonNode::class.java).body + ?: objectMapper.createObjectNode() + } + + fun getConfigurationLogs( + baseUrl: String, + adminToken: String, + configId: String, + page: Int, + size: Int, + level: String?, + source: String?, + ): JsonNode { + val params = mutableListOf("page=$page", "size=$size") + if (!level.isNullOrBlank()) params.add("level=$level") + if (!source.isNullOrBlank()) params.add("source=$source") + val path = "/api/host/configurations/$configId/logs" + val queryPath = "$path?${params.joinToString("&")}" + val uri = buildUri(baseUrl, queryPath) + // Deliberately signs `path` (without the query string), not `queryPath`: the plugin host + // strips the query string before verifying (`request.url.split("?")[0]` in hmac-auth.ts), + // so the canonical strings match. Query parameters are not signature-bound by design. + val headers = hmacHeaders(adminToken, HttpMethod.GET.name(), path, EMPTY_BODY) + val request = RequestEntity(headers, HttpMethod.GET, uri) + return restTemplate.exchange(request, JsonNode::class.java).body + ?: objectMapper.createObjectNode() + } + + /** + * Builds the HMAC signature headers shared by every GZAC→host request. The key is the host's + * decrypted secret (its `ADMIN_TOKEN`); the signature covers `{method}\n{path}\n{timestamp}\n + * {bodyHash}` and the timestamp gives the host a ±5-minute replay window. Routes with no request + * body pass [EMPTY_BODY]. + */ + private fun hmacHeaders( + secret: String, + method: String, + path: String, + body: ByteArray, + ): HttpHeaders { + val signer = ExternalPluginHmacSigner(secret) + val timestamp = Instant.now().toString() + val signature = signer.sign(method, path, timestamp, signer.bodyHash(body)) + return HttpHeaders().apply { + set(ExternalPluginHmacSigner.SIGNATURE_HEADER, signature) + set(ExternalPluginHmacSigner.TIMESTAMP_HEADER, timestamp) + } + } + + private fun parseBody(bytes: ByteArray): JsonNode? = if (bytes.isEmpty()) null else try { + objectMapper.readTree(bytes) + } catch (_: Exception) { + null + } + + private fun buildUri(baseUrl: String, path: String): URI { + val cleanedBase = baseUrl.trimEnd('/') + return URI.create("$cleanedBase$path") + } + + data class ActionResponse(val status: Int, val body: JsonNode?) + + companion object { + /** Error code surfaced when the plugin host cannot be reached at all (no HTTP response). */ + const val HOST_UNREACHABLE_ERROR_CODE = "EXTERNAL_PLUGIN_HOST_UNREACHABLE" + + private val EMPTY_BODY = ByteArray(0) + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/CompatibilityResult.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/CompatibilityResult.kt new file mode 100644 index 0000000000..c1d54a6d76 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/CompatibilityResult.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +/** + * Outcome of comparing a plugin's declared `compatibility` range against the running GZAC version. + * + * The platform only ever *warns* on incompatibility — it never blocks. [compatible] is therefore the + * single signal the UI gates on; [status] explains why for messaging and logging. When the current + * version cannot be determined the result is [compatible] = `true` with status + * [CompatibilityStatus.CURRENT_VERSION_UNKNOWN], so an undeterminable version never surfaces a false + * warning. + */ +data class CompatibilityResult( + val compatible: Boolean, + val currentGzacVersion: String?, + val minGzacVersion: String?, + val maxGzacVersion: String?, + val status: CompatibilityStatus, +) + +enum class CompatibilityStatus { + COMPATIBLE, + BELOW_MINIMUM, + ABOVE_MAXIMUM, + CURRENT_VERSION_UNKNOWN, +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProvider.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProvider.kt new file mode 100644 index 0000000000..07cc63fa88 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +/** + * Resolves the running GZAC version from, in order of precedence: + * + * 1. The `valtimo.external-plugin.gzac-version` property — an explicit operator override, useful in + * tests or when the build metadata is unavailable or wrong. + * 2. The Valtimo library version (the `Implementation-Version` stamped on every Valtimo module's + * jar manifest). A plugin's `compatibility` range targets the Valtimo *platform*, so this is the + * canonical source: it is the same value the UI sidebar shows for the backend (read by + * `com.ritense.valtimo.web.rest.VersionResource` off a core-module class), and it stays correct + * even when Valtimo is embedded in a downstream application whose own build version differs. + * + * Returns `null` when neither resolves (e.g. a dev run from class directories with no jar manifest), + * in which case compatibility cannot be judged. + */ +class DefaultGzacVersionProvider( + private val versionOverride: String?, + private val libraryVersion: String?, +) : GzacVersionProvider { + + override fun getCurrentVersion(): String? { + versionOverride?.takeIf { it.isNotBlank() }?.let { return it } + return libraryVersion?.takeIf { it.isNotBlank() } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityChecker.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityChecker.kt new file mode 100644 index 0000000000..0185a83c51 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityChecker.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.semver4j.Semver + +/** + * Compares an external plugin's declared `compatibility` range (`minGzacVersion` / `maxGzacVersion`, + * both optional and inclusive) against the running GZAC version from [GzacVersionProvider]. + * + * The range is lenient by design: an absent or unparseable bound is simply not enforced, and an + * undeterminable current version yields a compatible result. This matches the platform's + * warn-don't-block policy — the goal is to surface a likely mismatch, never to hard-fail on noisy + * version metadata. + */ +class GzacCompatibilityChecker( + private val versionProvider: GzacVersionProvider, +) { + + fun check(minGzacVersion: String?, maxGzacVersion: String?): CompatibilityResult { + val currentRaw = versionProvider.getCurrentVersion() + val current = currentRaw?.let { parseOrNull(it) } + + if (current == null) { + return CompatibilityResult( + compatible = true, + currentGzacVersion = currentRaw, + minGzacVersion = minGzacVersion, + maxGzacVersion = maxGzacVersion, + status = CompatibilityStatus.CURRENT_VERSION_UNKNOWN, + ) + } + + val min = minGzacVersion?.let { parseOrNull(it) } + if (min != null && current.compareTo(min) < 0) { + return result(currentRaw, minGzacVersion, maxGzacVersion, false, CompatibilityStatus.BELOW_MINIMUM) + } + + val max = maxGzacVersion?.let { parseOrNull(it) } + if (max != null && current.compareTo(max) > 0) { + return result(currentRaw, minGzacVersion, maxGzacVersion, false, CompatibilityStatus.ABOVE_MAXIMUM) + } + + return result(currentRaw, minGzacVersion, maxGzacVersion, true, CompatibilityStatus.COMPATIBLE) + } + + private fun result( + current: String?, + min: String?, + max: String?, + compatible: Boolean, + status: CompatibilityStatus, + ) = CompatibilityResult(compatible, current, min, max, status) + + private fun parseOrNull(version: String): Semver? { + return Semver.parse(version) ?: run { + logger.debug { "Ignoring unparseable version '$version' in external plugin compatibility check" } + null + } + } + + companion object { + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacVersionProvider.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacVersionProvider.kt new file mode 100644 index 0000000000..40342c0732 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/GzacVersionProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +/** + * Resolves the version of the running GZAC instance, used to judge whether an external plugin's + * declared `compatibility` range covers this deployment. A plugin's range targets the Valtimo + * *platform*, so the resolved version is the Valtimo library version (not the wrapping + * application's build version) — the same value the UI sidebar shows for the backend. Returns + * `null` when the version cannot be determined (e.g. an unpackaged dev run with no jar manifest or + * build metadata); callers treat an unknown version as "cannot judge" rather than raising a false + * incompatibility warning. + */ +fun interface GzacVersionProvider { + fun getCurrentVersion(): String? +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspector.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspector.kt new file mode 100644 index 0000000000..11c89556fc --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspector.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +/** + * The compatibility bounds declared under `compatibility` in a plugin's manifest. Either bound may + * be absent; an entry with neither bound is reported as `null` by [PluginPackageInspector] since + * there is then nothing to check. + */ +data class PluginCompatibilityRange( + val minGzacVersion: String?, + val maxGzacVersion: String?, +) + +/** + * Peeks at the `manifest.json` carried at the root of an uploaded plugin package (`.zip`) to read + * its declared compatibility range *before* GZAC forwards the upload to the plugin host. This lets + * the upload endpoint warn an operator that a plugin does not target this GZAC version while the + * host stays the sole authority on full manifest validity. + * + * Resilient by intent: a missing manifest, a missing `compatibility` block, or any read/parse + * failure yields `null` (no compatibility gate) rather than blocking the upload. + */ +class PluginPackageInspector( + private val objectMapper: ObjectMapper, +) { + + /** + * The package's full parsed `manifest.json`, or null when it is absent or unreadable. Used by + * the upload endpoint's overwrite flow to show the package's requested permissions for + * re-review and to reset grants to the newly declared sets after a confirmed overwrite. + */ + fun readManifest(zipBytes: ByteArray): JsonNode? { + val manifestBytes = readManifestBytes(zipBytes) ?: return null + return try { + objectMapper.readTree(manifestBytes) + } catch (e: Exception) { + logger.warn(e) { "Failed to parse manifest.json from uploaded plugin package" } + null + } + } + + fun readCompatibilityRange(zipBytes: ByteArray): PluginCompatibilityRange? { + val manifestBytes = readManifestBytes(zipBytes) ?: return null + return try { + val manifest = objectMapper.readTree(manifestBytes) + val compatibility = manifest.path("compatibility") + if (!compatibility.isObject) return null + val min = compatibility.get("minGzacVersion")?.asText()?.takeIf { it.isNotBlank() } + val max = compatibility.get("maxGzacVersion")?.asText()?.takeIf { it.isNotBlank() } + if (min == null && max == null) null else PluginCompatibilityRange(min, max) + } catch (e: Exception) { + logger.warn(e) { "Failed to parse manifest.json from uploaded plugin package" } + null + } + } + + /** + * Returns the bytes of the package's `manifest.json`. The pack tool and host both place it at + * the zip root, so a root entry wins; a nested `manifest.json` is accepted as a fallback for + * resilience. Reads are capped at [MAX_MANIFEST_BYTES] to bound memory on a hostile package. + */ + private fun readManifestBytes(zipBytes: ByteArray): ByteArray? { + return try { + ZipInputStream(ByteArrayInputStream(zipBytes)).use { zip -> + var fallback: ByteArray? = null + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.substringAfterLast('/') == MANIFEST_FILE_NAME) { + val bytes = zip.readNBytes(MAX_MANIFEST_BYTES) + if (entry.name == MANIFEST_FILE_NAME) return bytes + if (fallback == null) fallback = bytes + } + zip.closeEntry() + entry = zip.nextEntry + } + fallback + } + } catch (e: Exception) { + logger.warn(e) { "Failed to read uploaded plugin package as a zip" } + null + } + } + + companion object { + private const val MANIFEST_FILE_NAME = "manifest.json" + private const val MAX_MANIFEST_BYTES = 1024 * 1024 + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/EventQueueMode.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/EventQueueMode.kt new file mode 100644 index 0000000000..2e940b8c6e --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/EventQueueMode.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +/** + * How the plugin-host declares its per-host RabbitMQ queue. + * + * - [LIVE]: queue is `durable:false, autoDelete:true`. The queue evaporates when the host + * disconnects, so events published while the host is fully down are lost. Low overhead; + * the default for new and pre-existing hosts. + * - [DURABLE]: queue is `durable:true, autoDelete:false` with an `x-expires` (queue inactivity + * TTL) argument. The queue survives host restarts and accumulates events while the host is + * gone, up to the configured TTL since the last consumer disconnected. + */ +enum class EventQueueMode { LIVE, DURABLE } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapability.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapability.kt new file mode 100644 index 0000000000..5bad2491ba --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapability.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +/** + * The host functions an external plugin can be granted access to. Mirrors `HOST_CAPABILITIES` in + * the plugin SDK (`plugin-host/plugin-sdk/src/models/types.ts`) — the two lists must stay in sync. + * + * [value] is the wire/manifest representation (`manifest.permissions.capabilities`, the config + * push to the host, and the `capability` database column all carry this lowercase form). + */ +enum class ExternalPluginCapability(val value: String) { + GZAC_API("gzac_api"), + HTTP_REQUEST("http_request"), + KV("kv"), + LOG("log"), + + /** Allows the plugin's `handle_request` export to be invoked via the host's public data route. */ + FRONTEND_DATA("frontend_data"); + + companion object { + fun fromValue(value: String): ExternalPluginCapability = entries.firstOrNull { it.value == value } + ?: throw IllegalArgumentException( + "Unknown capability '$value'. Known capabilities: ${entries.joinToString(", ") { it.value }}" + ) + } +} + +/** + * Persists the wire representation ([ExternalPluginCapability.value]) rather than the enum name, + * so the database column holds the same identifier the manifest and the host protocol use. + */ +@Converter +class ExternalPluginCapabilityConverter : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: ExternalPluginCapability): String = attribute.value + + override fun convertToEntityAttribute(dbData: String): ExternalPluginCapability = + ExternalPluginCapability.fromValue(dbData) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginConfiguration.kt new file mode 100644 index 0000000000..b54fb3d2d6 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginConfiguration.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import com.fasterxml.jackson.databind.node.ObjectNode +import io.hypersistence.utils.hibernate.type.json.JsonType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Type +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "external_plugin_configuration") +class ExternalPluginConfiguration( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "definition_id", nullable = false) + val definitionId: UUID, + + @Column(name = "title", nullable = false) + var title: String, + + @Type(value = JsonType::class) + @Column(name = "properties", columnDefinition = "JSON") + var properties: ObjectNode? = null, + + @Column(name = "created_at", nullable = false) + val createdAt: Instant = Instant.now(), + + /** + * Revocation counter for the tokens minted for this configuration. Every issued service/user + * token carries the generation it was minted under; a token only validates while its generation + * matches this value. Bumping the counter therefore kills every outstanding token instantly — + * the discovery cycle re-pushes a fresh token of the new generation to the host. + */ + @Column(name = "token_generation", nullable = false) + var tokenGeneration: Long = 0, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinition.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinition.kt new file mode 100644 index 0000000000..51be3826e1 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinition.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import com.fasterxml.jackson.databind.node.ObjectNode +import io.hypersistence.utils.hibernate.type.json.JsonType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hibernate.annotations.Type +import java.util.UUID + +@Entity +@Table( + name = "external_plugin_definition", + uniqueConstraints = [ + UniqueConstraint( + name = "external_plugin_definition_plugin_id_version_uq", + columnNames = ["plugin_id", "version"] + ) + ] +) +class ExternalPluginDefinition( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "plugin_id", nullable = false) + val pluginId: String, + + @Column(name = "version", nullable = false) + val version: String, + + @Column(name = "name") + var name: String? = null, + + @Column(name = "description") + var description: String? = null, + + @Column(name = "provider") + var provider: String? = null, + + @Column(name = "min_gzac_version") + var minGzacVersion: String? = null, + + @Column(name = "max_gzac_version") + var maxGzacVersion: String? = null, + + @Type(value = JsonType::class) + @Column(name = "config_schema", columnDefinition = "JSON") + var configSchema: ObjectNode? = null, + + @Type(value = JsonType::class) + @Column(name = "manifest_json", columnDefinition = "JSON") + var manifestJson: ObjectNode? = null, + + @Column(name = "host_id", nullable = false) + val hostId: UUID, + + @Column(name = "base_url", nullable = false) + var baseUrl: String, + + @Column(name = "status", nullable = false) + @Enumerated(EnumType.STRING) + var status: ExternalPluginDefinitionStatus, + + @Column(name = "consecutive_misses", nullable = false) + var consecutiveMisses: Int = 0, + + /** + * The package content hash (manifest + wasm + frontend bundles) pinned at discovery. What runs + * on the host is only trusted while it still matches this value. + */ + @Column(name = "content_hash") + var contentHash: String? = null, + + /** + * Set when discovery finds the host serving *different* content under this pluginId@version + * than what was pinned. While set, configuration pushes, plugin invocations and user-token + * minting are withheld until an admin explicitly re-accepts the new content. + */ + @Column(name = "pending_content_hash") + var pendingContentHash: String? = null, +) { + + /** True when the host's package changed after acceptance and an admin has not re-accepted it. */ + val requiresReacceptance: Boolean + get() = pendingContentHash != null +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinitionStatus.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinitionStatus.kt new file mode 100644 index 0000000000..fee4577c94 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginDefinitionStatus.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +enum class ExternalPluginDefinitionStatus { + AVAILABLE, + UNAVAILABLE, +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedCapability.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedCapability.kt new file mode 100644 index 0000000000..d90523f290 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedCapability.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant +import java.util.UUID + +@Entity +@Table( + name = "external_plugin_granted_capability", + uniqueConstraints = [ + UniqueConstraint( + name = "ext_plugin_granted_cap_config_capability_uq", + columnNames = ["configuration_id", "capability"] + ) + ] +) +class ExternalPluginGrantedCapability( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "configuration_id", nullable = false) + val configurationId: UUID, + + @Column(name = "capability", nullable = false, length = 64) + @Convert(converter = ExternalPluginCapabilityConverter::class) + val capability: ExternalPluginCapability, + + @Column(name = "granted_at", nullable = false) + val grantedAt: Instant = Instant.now(), +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEndpoint.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEndpoint.kt new file mode 100644 index 0000000000..93de2074d9 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEndpoint.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant +import java.util.UUID + +@Entity +@Table( + name = "external_plugin_granted_endpoint", + uniqueConstraints = [ + UniqueConstraint( + name = "ext_plugin_granted_ep_config_method_pattern_uq", + columnNames = ["configuration_id", "http_method", "endpoint_pattern"] + ) + ] +) +class ExternalPluginGrantedEndpoint( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "configuration_id", nullable = false) + val configurationId: UUID, + + @Column(name = "http_method", nullable = false) + val httpMethod: String, + + @Column(name = "endpoint_pattern", nullable = false) + val endpointPattern: String, + + @Column(name = "granted_at", nullable = false) + val grantedAt: Instant = Instant.now(), +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEvent.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEvent.kt new file mode 100644 index 0000000000..a44f8de807 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginGrantedEvent.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant +import java.util.UUID + +/** + * A CloudEvent type the admin granted a plugin configuration permission to receive. Acts as the + * authoritative subscription list pushed to the host: the host only dispatches events whose type + * is in this set, narrower or equal to the plugin manifest's declared `eventSubscriptions`. A + * later manifest update that adds event types does not silently widen access — a new grant is + * required. + */ +@Entity +@Table( + name = "external_plugin_granted_event", + uniqueConstraints = [ + UniqueConstraint( + name = "ext_plugin_granted_evt_config_event_type_uq", + columnNames = ["configuration_id", "event_type"] + ) + ] +) +class ExternalPluginGrantedEvent( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "configuration_id", nullable = false) + val configurationId: UUID, + + @Column(name = "event_type", nullable = false) + val eventType: String, + + @Column(name = "granted_at", nullable = false) + val grantedAt: Instant = Instant.now(), +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHost.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHost.kt new file mode 100644 index 0000000000..f19eea25b1 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHost.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "external_plugin_host") +class ExternalPluginHost( + + @Id + @Column(name = "id") + val id: UUID, + + @Column(name = "name", nullable = false) + var name: String, + + @Column(name = "base_url", nullable = false) + var baseUrl: String, + + @Column(name = "secret", nullable = false) + var secret: String, + + @Column(name = "status", nullable = false) + @Enumerated(EnumType.STRING) + var status: ExternalPluginHostStatus, + + @Column(name = "last_health_check") + var lastHealthCheck: Instant? = null, + + @Column(name = "consecutive_failures", nullable = false) + var consecutiveFailures: Int = 0, + + /** + * Whether this row is a multi-plugin [PLUGIN_HOST][ExternalPluginHostKind.PLUGIN_HOST] (plugins + * uploaded as `.wasm`) or an [APP][ExternalPluginHostKind.APP] (a remote service added by URL + * that serves its own single plugin and accepts no uploads). Defaults to `PLUGIN_HOST` for + * pre-existing rows. + */ + @Column(name = "kind", nullable = false) + @Enumerated(EnumType.STRING) + var kind: ExternalPluginHostKind = ExternalPluginHostKind.PLUGIN_HOST, + + /** + * URL the plugin host uses to call back into GZAC. Pre-filled in the add-host UI from the URL + * the admin reaches GZAC at and editable per host. Null only on legacy rows; pushes fall back + * to `http://localhost:{server.port}` in that case. + */ + @Column(name = "gzac_callback_base_url") + var gzacCallbackBaseUrl: String? = null, + + /** + * AMQP URL the plugin host uses to consume this instance's event stream. Pre-filled in the + * add-host UI from `spring.rabbitmq.*` and editable per host. Null disables event delivery + * for this host (actions still work). + */ + @Column(name = "event_broker_amqp_url") + var eventBrokerAmqpUrl: String? = null, + + /** + * Exchange the plugin host binds to. Null falls back to `valtimo.outbox.publisher.rabbitmq.exchange` + * at push time — the exchange GZAC itself publishes to. + */ + @Column(name = "event_broker_exchange") + var eventBrokerExchange: String? = null, + + /** + * Per-host event-queue declaration mode. LIVE keeps today's autoDelete semantics; DURABLE + * survives host restarts. Pushed alongside the broker connection on every configuration push, + * so the plugin-host can switch its `assertQueue` arguments without any out-of-band coordination. + */ + @Column(name = "event_queue_mode", nullable = false) + @Enumerated(EnumType.STRING) + var eventQueueMode: EventQueueMode = EventQueueMode.LIVE, + + /** + * Queue inactivity TTL in milliseconds, used only when [eventQueueMode] is DURABLE. Maps to + * RabbitMQ's `x-expires` queue argument. Required to be `null` when mode is LIVE. + */ + @Column(name = "event_queue_ttl_ms") + var eventQueueTtlMs: Long? = null, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostKind.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostKind.kt new file mode 100644 index 0000000000..b5865a0e53 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostKind.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +/** + * The kind of remote integration a row in `external_plugin_host` represents. Both kinds speak the + * exact same HTTP contract to GZAC (discovery, HMAC-signed pushes/actions, iframe/data routes), so + * everything downstream of registration is shared; the kind only drives the admin UX and a couple + * of registration-time behaviours. + * + * - [PLUGIN_HOST]: a multi-plugin, multi-version host that plugins are uploaded to as `.wasm` + * packages (the Extism-based `plugin-host/app`). The default for new and pre-existing rows. + * - [APP]: a remote service, added by URL, that *is* a plugin-host-plus-single-plugin — it serves + * its own single, natively-implemented plugin and accepts no uploads. GZAC discovers that plugin + * immediately on registration. + */ +enum class ExternalPluginHostKind { PLUGIN_HOST, APP } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostStatus.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostStatus.kt new file mode 100644 index 0000000000..d93948a96f --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginHostStatus.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +enum class ExternalPluginHostStatus { + CONNECTED, + UNREACHABLE, +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt new file mode 100644 index 0000000000..96cfe03fb4 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import io.hypersistence.utils.hibernate.type.json.JsonType +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import org.hibernate.annotations.Type +import java.util.UUID + +/** + * Service-task action link. [pluginConfigurationReference] carries `pluginId` (as + * [PluginConfigurationReference.pluginDefinitionKey]) and the manifest version as design-time + * metadata only (validation, UI warnings, import chooser). The **runtime** invocation version + * always derives from the resolved configuration's definition + * ([externalPluginConfigurationId] -> configuration -> definition), never from this reference — + * invoking one version's plugin code with another version's configuration must be impossible. + * + * Reuses [PluginConfigurationReference] — the same embeddable mapped by the embedded-plugin + * `PluginProcessLink` — on the same `process_link` columns (`reference_type`, + * `plugin_definition_key`, `plugin_definition_version`); verified safe for two STI siblings to + * share by `PluginConfigurationReferenceSharedStiColumnsTest` in `:backend:plugin`. + */ +@Entity +@DiscriminatorValue(PROCESS_LINK_TYPE) +class ExternalPluginProcessLink( + id: UUID, + processDefinitionId: String, + activityId: String, + activityType: ActivityTypeWithEventName, + + @Column(name = "external_plugin_config_id") + val externalPluginConfigurationId: UUID?, + + @Column(name = "external_plugin_action_key") + val actionKey: String, + + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), + + @Type(value = JsonType::class) + @Column(name = "external_plugin_action_properties", columnDefinition = "JSON") + val actionProperties: ObjectNode? = null, + + @Type(value = JsonType::class) + @Column(name = "action_result_mappings", columnDefinition = "JSON") + val actionResultMappings: List = emptyList(), +) : ProcessLink( + id, + processDefinitionId, + activityId, + activityType, + PROCESS_LINK_TYPE, +) { + + override fun copy(id: UUID, processDefinitionId: String) = copy( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + ) + + fun copy( + id: UUID = this.id, + processDefinitionId: String = this.processDefinitionId, + activityId: String = this.activityId, + activityType: ActivityTypeWithEventName = this.activityType, + externalPluginConfigurationId: UUID? = this.externalPluginConfigurationId, + actionKey: String = this.actionKey, + pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, + actionProperties: ObjectNode? = this.actionProperties, + actionResultMappings: List = this.actionResultMappings, + ) = ExternalPluginProcessLink( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = activityType, + externalPluginConfigurationId = externalPluginConfigurationId, + actionKey = actionKey, + pluginConfigurationReference = pluginConfigurationReference, + actionProperties = actionProperties, + actionResultMappings = actionResultMappings, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as ExternalPluginProcessLink + + if (externalPluginConfigurationId != other.externalPluginConfigurationId) return false + if (actionKey != other.actionKey) return false + if (pluginConfigurationReference != other.pluginConfigurationReference) return false + if (actionProperties != other.actionProperties) return false + if (actionResultMappings != other.actionResultMappings) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + (externalPluginConfigurationId?.hashCode() ?: 0) + result = 31 * result + actionKey.hashCode() + result = 31 * result + pluginConfigurationReference.hashCode() + result = 31 * result + (actionProperties?.hashCode() ?: 0) + result = 31 * result + actionResultMappings.hashCode() + return result + } + + companion object { + const val PROCESS_LINK_TYPE = "external_plugin" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt new file mode 100644 index 0000000000..32e682be07 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import java.util.UUID + +/** + * A [ProcessLink] that renders an external plugin's `task-form` frontend bundle for a user task. + * Unlike [ExternalPluginProcessLink] (a service-task action invoked by a listener) this surface has + * no backend action: the plugin serves the form UI in an iframe and completes the task itself, under + * the downscoped user token. The link only records which plugin configuration and which `task-form` + * bundle to render — [bundleKey] is optional and, when null, the plugin's sole `task-form` bundle is + * used. + * + * [pluginConfigurationReference] carries the design-time-only `pluginId`/version metadata, mirroring + * [ExternalPluginProcessLink] — it reuses the same shared `process_link` columns + * (`reference_type`, `plugin_definition_key`, `plugin_definition_version`) rather than a + * task-form-specific version column. The **runtime** invocation version always derives from the + * resolved configuration's definition, never from this reference. + */ +@Entity +@DiscriminatorValue(PROCESS_LINK_TYPE) +class ExternalPluginTaskFormProcessLink( + id: UUID, + processDefinitionId: String, + activityId: String, + activityType: ActivityTypeWithEventName, + + @Column(name = "external_plugin_task_form_config_id") + val externalPluginConfigurationId: UUID, + + @Column(name = "external_plugin_task_form_bundle_key") + val bundleKey: String? = null, + + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), +) : ProcessLink( + id, + processDefinitionId, + activityId, + activityType, + PROCESS_LINK_TYPE, +) { + + override fun copy(id: UUID, processDefinitionId: String) = copy( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + ) + + fun copy( + id: UUID = this.id, + processDefinitionId: String = this.processDefinitionId, + activityId: String = this.activityId, + activityType: ActivityTypeWithEventName = this.activityType, + externalPluginConfigurationId: UUID = this.externalPluginConfigurationId, + bundleKey: String? = this.bundleKey, + pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, + ) = ExternalPluginTaskFormProcessLink( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = activityType, + externalPluginConfigurationId = externalPluginConfigurationId, + bundleKey = bundleKey, + pluginConfigurationReference = pluginConfigurationReference, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as ExternalPluginTaskFormProcessLink + + if (externalPluginConfigurationId != other.externalPluginConfigurationId) return false + if (bundleKey != other.bundleKey) return false + if (pluginConfigurationReference != other.pluginConfigurationReference) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + externalPluginConfigurationId.hashCode() + result = 31 * result + (bundleKey?.hashCode() ?: 0) + result = 31 * result + pluginConfigurationReference.hashCode() + return result + } + + companion object { + const val PROCESS_LINK_TYPE = "external_plugin_task_form" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginActionFailedException.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginActionFailedException.kt new file mode 100644 index 0000000000..17a7b14fbf --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginActionFailedException.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.exception + +/** + * Thrown when an external plugin action returns a non-success response — a 4xx plugin-level error + * (e.g. the plugin rejected the input) or a 5xx host/infrastructure error. + * + * This is deliberately a plain [RuntimeException] rather than an Operaton `BpmnError`. External + * plugin actions execute from a service-task **execution listener** that is bridged through the + * `@Transactional` `OperatonEventListener`. A `BpmnError` thrown from that path is *not* routed to + * BPMN error boundary events (Operaton only catches `BpmnError`s raised by an activity's behaviour, + * not by execution listeners) and, when uncaught, it leaves the surrounding transaction marked + * rollback-only. The commit then fails with an opaque + * `"Transaction silently rolled back because it has been marked as rollback-only"` message, hiding + * the real cause on the resulting job incident. Throwing a normal exception instead lets the actual + * [errorCode] and message propagate to the failed job and its incident, so operators can see why the + * plugin failed. + */ +class ExternalPluginActionFailedException( + val errorCode: String, + message: String, +) : RuntimeException(message) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginConfigurationInUseException.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginConfigurationInUseException.kt new file mode 100644 index 0000000000..5c82532158 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginConfigurationInUseException.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.exception + +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import org.zalando.problem.AbstractThrowableProblem +import org.zalando.problem.Exceptional +import org.zalando.problem.Status +import java.util.UUID + +class ExternalPluginConfigurationInUseException( + configurationId: UUID, + usages: Collection, +) : AbstractThrowableProblem( + null, + "External plugin configuration is in use", + Status.CONFLICT, + "One or more BPMN process links, case tabs or case widgets reference this configuration. " + + "Remove the references before deleting the configuration.", + null, + null, + mapOf( + "configurationId" to configurationId.toString(), + "usages" to usages, + ), +) { + override fun getCause(): Exceptional? = null +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseException.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseException.kt new file mode 100644 index 0000000000..7b6809a6f5 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseException.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.exception + +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import org.zalando.problem.AbstractThrowableProblem +import org.zalando.problem.Exceptional +import org.zalando.problem.Status +import java.util.UUID + +class ExternalPluginHostInUseException( + hostId: UUID, + usages: Collection, +) : AbstractThrowableProblem( + null, + "External plugin host is in use", + Status.CONFLICT, + "One or more BPMN process links, case tabs or case widgets reference configurations under " + + "this host. Remove the references before deleting the host.", + null, + null, + mapOf( + "hostId" to hostId.toString(), + "usages" to usages, + ), +) { + override fun getCause(): Exceptional? = null +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginNotFoundException.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginNotFoundException.kt new file mode 100644 index 0000000000..adf0a405bd --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/exception/ExternalPluginNotFoundException.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.exception + +import org.zalando.problem.AbstractThrowableProblem +import org.zalando.problem.Exceptional +import org.zalando.problem.Status +import java.util.UUID + +/** + * Thrown when an external-plugin resource (host, definition, configuration) does not exist. + * A Problem with [Status.NOT_FOUND] so a lookup by unknown id surfaces as `404 Not Found` + * instead of a 500. + */ +class ExternalPluginNotFoundException( + resource: String, + id: UUID, +) : AbstractThrowableProblem( + null, + "$resource not found", + Status.NOT_FOUND, + "$resource $id not found", +) { + override fun getCause(): Exceptional? = null +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt new file mode 100644 index 0000000000..8d3a760af8 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.preview + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.importer.ImportPreviewContribution +import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import java.util.UUID + +/** + * Surfaces external-plugin configuration references in the case-definition import preview, mirroring + * `PluginConfigurationImportPreviewContributor` (embedded plugins) for the two places an + * `externalPluginConfigurationId` can appear: `*.process-link.json` (`external_plugin` / + * `external_plugin_task_form` links) and `*.case-tab.json` (`EXTERNAL_PLUGIN` tabs, whose config id + * is embedded in `contentKey` as `"[:]"`). + */ +class ExternalPluginImportPreviewContributor( + private val objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, +) : ImportPreviewContributor { + + override fun contributePreview(zipEntries: Map): List { + val result = mutableListOf() + for ((fileName, content) in zipEntries) { + when { + PROCESS_LINK_REGEX.matches(fileName) -> result += contributeFromProcessLink(fileName, content) + CASE_TAB_REGEX.matches(fileName) -> result += contributeFromCaseTab(fileName, content) + CASE_WIDGET_TAB_REGEX.matches(fileName) -> result += contributeFromCaseWidgetTab(fileName, content) + } + } + return result + } + + private fun contributeFromProcessLink(fileName: String, content: ByteArray): List { + val match = PROCESS_LINK_REGEX.matchEntire(fileName) ?: return emptyList() + val processDefinitionKey = match.groupValues[1] + + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (node in jsonTree) { + val processLinkType = node.path("processLinkType").asText(null) ?: continue + if (processLinkType != "external_plugin" && processLinkType != "external_plugin_task_form") continue + + val referenceType = node.path("referenceType").asText("FIXED") + if (referenceType != "FIXED") continue + + val configIdText = node.path("externalPluginConfigurationId").asText(null) ?: continue + val configId = configIdText.toUuidOrNull() ?: continue + + val pluginDefinitionKey = node.path("pluginDefinitionKey").asText(null) + val pluginDefinitionVersion = node.path("pluginVersion").asText(null) + val activityId = node.path("activityId").asText(null) ?: continue + val actionKey = node.path("actionKey").asText(processLinkType) + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = pluginDefinitionKey, + pluginActionDefinitionKey = actionKey, + processDefinitionKey = processDefinitionKey, + activityId = activityId, + existsInTargetEnvironment = configurationRepository.existsById(configId), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + ) + } + return result + } + + private fun contributeFromCaseTab(fileName: String, content: ByteArray): List { + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (node in jsonTree) { + val type = node.path("type").asText(null) ?: continue + if (type != "external_plugin") continue + + val contentKey = node.path("contentKey").asText(null) ?: continue + val configId = contentKey.substringBefore(':').toUuidOrNull() ?: continue + val tabKey = node.path("key").asText(fileName) + + // Self-describing exports carry the tab's plugin key/version directly (like a process + // link), so the plugin stays identifiable even when the referenced configuration was + // deleted in the target. Fall back to resolving through the configuration for exports + // produced before that field existed; without a pluginDefinitionKey the import wizard + // filters the row out as "unidentifiable" (unmappable), as before. + val configuration = configurationRepository.findById(configId).orElse(null) + val definition = configuration?.let { definitionRepository.findById(it.definitionId).orElse(null) } + val pluginDefinitionKey = node.path("pluginDefinitionKey").asText(null) ?: definition?.pluginId + val pluginDefinitionVersion = node.path("pluginVersion").asText(null) ?: definition?.version + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = pluginDefinitionKey, + pluginActionDefinitionKey = "case-tab", + processDefinitionKey = fileName, + activityId = tabKey, + existsInTargetEnvironment = configuration != null, + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + ) + } + return result + } + + /** + * A `*.case-widget-tab.json` holds a list of widget tabs; each tab's `widgets[]` may contain + * `external-plugin` widgets whose `properties.configurationId` references a plugin configuration. + * Emits one contribution per such widget, mirroring [contributeFromCaseTab]. Self-describing + * exports carry the widget's plugin key/version in `properties`, so the plugin stays identifiable + * even when the referenced configuration was deleted in the target; older exports fall back to + * resolving through the configuration. + */ + private fun contributeFromCaseWidgetTab(fileName: String, content: ByteArray): List { + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (tabNode in jsonTree) { + val tabKey = tabNode.path("key").asText(null) + val widgets = tabNode.path("widgets") + if (!widgets.isArray) continue + + for (widgetNode in widgets) { + val type = widgetNode.path("type").asText(null) ?: continue + if (type != "external-plugin") continue + + val properties = widgetNode.path("properties") + val configIdText = properties.path("configurationId").asText(null) ?: continue + val configId = configIdText.toUuidOrNull() ?: continue + val widgetKey = widgetNode.path("key").asText(tabKey ?: fileName) + + val configuration = configurationRepository.findById(configId).orElse(null) + val definition = configuration?.let { definitionRepository.findById(it.definitionId).orElse(null) } + val pluginDefinitionKey = properties.path("pluginDefinitionKey").asText(null) ?: definition?.pluginId + val pluginDefinitionVersion = properties.path("pluginDefinitionVersion").asText(null) ?: definition?.version + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = pluginDefinitionKey, + pluginActionDefinitionKey = "case-widget", + processDefinitionKey = fileName, + activityId = if (tabKey != null) "$tabKey/$widgetKey" else widgetKey, + existsInTargetEnvironment = configuration != null, + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + ) + } + } + return result + } + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } + + private companion object { + val PROCESS_LINK_REGEX = """.*/?process-link/(?:.*/)?(.+)\.process-link\.json""".toRegex() + val CASE_TAB_REGEX = """.*/?case/tab/([^/]+)\.case-tab\.json""".toRegex() + val CASE_WIDGET_TAB_REGEX = """.*/?case/widget-tab/([^/]+)\.case-widget-tab\.json""".toRegex() + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt new file mode 100644 index 0000000000..665b10f96d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt @@ -0,0 +1,398 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkDeployDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkExportResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.domain.PluginConfigurationReferenceType.BUILDING_BLOCK +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED +import com.ritense.plugin.service.PluginActionResultMappingValidator +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField +import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto +import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto +import com.ritense.valtimo.contract.BlueprintId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valueresolver.exception.ValueResolverValidationException +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher +import java.util.UUID + +/** + * Reference-model invariants mirror `PluginProcessLinkMapper.validateReference`: + * - `FIXED`: [ExternalPluginProcessLinkCreateRequestDto.externalPluginConfigurationId] is required + * (nullable only for dangling imports); `pluginDefinitionKey`/`pluginVersion` are + * *derived from the configuration* at save time — the frontend only ever sends the config id. + * - `BUILDING_BLOCK`: config id must be `NULL`; `pluginDefinitionKey` + `pluginVersion` are required + * from the DTO. + */ +class ExternalPluginProcessLinkMapper( + objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val processLinkRepository: ExternalPluginProcessLinkRepository, +) : ProcessLinkMapper { + + init { + objectMapper.registerSubtypes( + ExternalPluginProcessLinkCreateRequestDto::class.java, + ExternalPluginProcessLinkUpdateRequestDto::class.java, + ExternalPluginProcessLinkResponseDto::class.java, + ExternalPluginProcessLinkDeployDto::class.java, + ExternalPluginProcessLinkExportResponseDto::class.java, + ) + } + + override fun supportsProcessLinkType(processLinkType: String) = processLinkType == PROCESS_LINK_TYPE + + override fun toProcessLinkResponseDto(processLink: ProcessLink): ProcessLinkResponseDto { + processLink as ExternalPluginProcessLink + return ExternalPluginProcessLinkResponseDto( + id = processLink.id, + processDefinitionId = processLink.processDefinitionId, + activityId = processLink.activityId, + activityType = processLink.activityType, + externalPluginConfigurationId = processLink.externalPluginConfigurationId, + actionKey = processLink.actionKey, + actionProperties = processLink.actionProperties, + referenceType = processLink.pluginConfigurationReference.type, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + actionResultMappings = processLink.actionResultMappings, + ) + } + + override fun toNewProcessLink(createRequestDto: ProcessLinkCreateRequestDto, blueprintId: BlueprintId?): ProcessLink { + createRequestDto as ExternalPluginProcessLinkCreateRequestDto + val reference = createReference( + createRequestDto.referenceType, + createRequestDto.externalPluginConfigurationId, + createRequestDto.pluginDefinitionKey, + createRequestDto.pluginVersion, + ) + validateReference(reference.type, createRequestDto.externalPluginConfigurationId) + PluginActionResultMappingValidator.validate(createRequestDto.actionResultMappings) + validateActionResultMappingSources( + createRequestDto.externalPluginConfigurationId, + reference, + createRequestDto.actionKey, + createRequestDto.actionResultMappings, + ) + return ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = createRequestDto.processDefinitionId, + activityId = createRequestDto.activityId, + activityType = createRequestDto.activityType, + externalPluginConfigurationId = createRequestDto.externalPluginConfigurationId, + actionKey = createRequestDto.actionKey, + pluginConfigurationReference = reference, + actionProperties = createRequestDto.actionProperties, + actionResultMappings = createRequestDto.actionResultMappings, + ) + } + + override fun toUpdatedProcessLink( + processLinkToUpdate: ProcessLink, + updateRequestDto: ProcessLinkUpdateRequestDto, + blueprintId: BlueprintId?, + ): ProcessLink { + updateRequestDto as ExternalPluginProcessLinkUpdateRequestDto + assert(processLinkToUpdate.id == updateRequestDto.id) + val reference = createReference( + updateRequestDto.referenceType, + updateRequestDto.externalPluginConfigurationId, + updateRequestDto.pluginDefinitionKey, + updateRequestDto.pluginVersion, + ) + validateReference(reference.type, updateRequestDto.externalPluginConfigurationId) + PluginActionResultMappingValidator.validate(updateRequestDto.actionResultMappings) + validateActionResultMappingSources( + updateRequestDto.externalPluginConfigurationId, + reference, + updateRequestDto.actionKey, + updateRequestDto.actionResultMappings, + ) + return ExternalPluginProcessLink( + id = updateRequestDto.id, + processDefinitionId = processLinkToUpdate.processDefinitionId, + activityId = processLinkToUpdate.activityId, + activityType = processLinkToUpdate.activityType, + externalPluginConfigurationId = updateRequestDto.externalPluginConfigurationId, + actionKey = updateRequestDto.actionKey, + pluginConfigurationReference = reference, + actionProperties = updateRequestDto.actionProperties, + actionResultMappings = updateRequestDto.actionResultMappings, + ) + } + + override fun toProcessLinkCreateRequestDto(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): ProcessLinkCreateRequestDto { + deployDto as ExternalPluginProcessLinkDeployDto + return ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = deployDto.processDefinitionId, + activityId = deployDto.activityId, + activityType = deployDto.activityType, + externalPluginConfigurationId = deployDto.externalPluginConfigurationId, + actionKey = deployDto.actionKey, + actionProperties = deployDto.actionProperties, + referenceType = deployDto.referenceType, + pluginDefinitionKey = deployDto.pluginDefinitionKey, + pluginVersion = deployDto.pluginVersion, + actionResultMappings = deployDto.actionResultMappings, + ) + } + + override fun toProcessLinkUpdateRequestDto( + deployDto: ProcessLinkDeployDto, + existingProcessLinkId: UUID, + blueprintId: BlueprintId?, + ): ProcessLinkUpdateRequestDto { + deployDto as ExternalPluginProcessLinkDeployDto + return ExternalPluginProcessLinkUpdateRequestDto( + id = existingProcessLinkId, + externalPluginConfigurationId = deployDto.externalPluginConfigurationId, + actionKey = deployDto.actionKey, + actionProperties = deployDto.actionProperties, + referenceType = deployDto.referenceType, + pluginDefinitionKey = deployDto.pluginDefinitionKey, + pluginVersion = deployDto.pluginVersion, + actionResultMappings = deployDto.actionResultMappings, + ) + } + + override fun toProcessLinkExportResponseDto(processLink: ProcessLink): ProcessLinkExportResponseDto { + processLink as ExternalPluginProcessLink + return ExternalPluginProcessLinkExportResponseDto( + activityId = processLink.activityId, + activityType = processLink.activityType, + externalPluginConfigurationId = processLink.externalPluginConfigurationId, + actionKey = processLink.actionKey, + actionProperties = processLink.actionProperties, + referenceType = processLink.pluginConfigurationReference.type, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + actionResultMappings = processLink.actionResultMappings, + ) + } + + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "externalPluginConfigurationId", mappings) + } + + /** + * Mirrors [com.ritense.valtimo.processlink.mapper.PluginProcessLinkMapper.afterImport]: a `FIXED` + * reference whose configuration id is `null` (left dangling by the import wizard) or no longer + * resolves to an existing configuration is a configuration issue. `BUILDING_BLOCK` references are + * never dangling in this sense — they have no configuration id to lose. + */ + override fun afterImport( + caseDefinitionId: CaseDefinitionId, + processDefinitionIds: Set, + applicationEventPublisher: ApplicationEventPublisher + ) { + val allLinks = processDefinitionIds.flatMap { pdId -> processLinkRepository.findByProcessDefinitionId(pdId) } + + val hasIssue = allLinks.any { link -> + if (link.pluginConfigurationReference.type != FIXED) { + return@any false + } + + val configId = link.externalPluginConfigurationId ?: return@any true + !configurationRepository.existsById(configId) + } + + if (hasIssue) { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } else { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } + } + + /** + * For `FIXED`, `pluginDefinitionKey`/`pluginVersion` are always derived from the configuration + * — even if the caller (deploy DTOs, legacy imports) supplied values, those are ignored so the + * reference can never drift from the actual configuration it points at. For `BUILDING_BLOCK`, + * both must be supplied by the caller since there is no configuration id to derive them from. + */ + private fun createReference( + type: PluginConfigurationReferenceType, + externalPluginConfigurationId: UUID?, + pluginDefinitionKey: String?, + pluginVersion: String?, + ): PluginConfigurationReference { + return when (type) { + FIXED -> { + val definition = externalPluginConfigurationId?.let { configId -> + configurationRepository.findById(configId) + .map { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + .orElse(null) + } + PluginConfigurationReference( + type = type, + pluginDefinitionKey = definition?.pluginId ?: pluginDefinitionKey, + pluginDefinitionVersion = definition?.version ?: pluginVersion, + ) + } + BUILDING_BLOCK -> PluginConfigurationReference( + type = type, + pluginDefinitionKey = requireNotNull(pluginDefinitionKey) { + "pluginDefinitionKey is required when reference type is BUILDING_BLOCK" + }, + pluginDefinitionVersion = requireNotNull(pluginVersion) { + "pluginVersion is required when reference type is BUILDING_BLOCK" + }, + ) + } + } + + private fun validateReference( + type: PluginConfigurationReferenceType, + externalPluginConfigurationId: UUID?, + ) { + when (type) { + FIXED -> {} // externalPluginConfigurationId may be null during import (dangling) + BUILDING_BLOCK -> require(externalPluginConfigurationId == null) { + "externalPluginConfigurationId must be empty when reference type is BUILDING_BLOCK" + } + } + } + + /** + * A mapping source is only meaningful when the action declares its output shape in the + * manifest — there is no free-text pointer anymore, the frontend stepper offers a dropdown of + * the declared `outputs` keys. Resolves the link's definition ([FIXED] via the configuration, + * [BUILDING_BLOCK] via `pluginId`+version) and checks each mapping's first JSON-pointer segment + * is a declared key; an action with no/empty `outputs` cannot carry any mappings at all. + * Lenient (skip validation, log a warning) when the definition or its manifest can't be + * resolved — import/legacy scenarios where the plugin isn't installed yet or predates this + * feature must not block saving the link. + */ + private fun validateActionResultMappingSources( + externalPluginConfigurationId: UUID?, + reference: PluginConfigurationReference, + actionKey: String, + mappings: List, + ) { + if (mappings.isEmpty()) { + return + } + + val definition = resolveDefinition(externalPluginConfigurationId, reference) + if (definition == null) { + logger.warn { + "Could not resolve the definition for external plugin process link action '$actionKey' " + + "(reference '${reference.pluginDefinitionKey}@${reference.pluginDefinitionVersion}') — " + + "skipping action result mapping source validation" + } + return + } + + val declaredOutputs = declaredActionOutputs(definition, actionKey) + if (declaredOutputs == null) { + logger.warn { + "Plugin '${definition.pluginId}@${definition.version}' manifest does not declare its " + + "'actions' — skipping action result mapping source validation for action '$actionKey'" + } + return + } + + if (declaredOutputs.isEmpty()) { + throw ValueResolverValidationException( + "Action '$actionKey' of plugin '${definition.pluginId}@${definition.version}' does not " + + "declare any outputs — it cannot be used with action result mappings" + ) + } + + mappings.forEach { mapping -> + val key = mapping.source.removePrefix("/").substringBefore("/") + if (key !in declaredOutputs) { + throw ValueResolverValidationException( + "Action result mapping source '${mapping.source}' does not match a declared output of " + + "action '$actionKey' (plugin '${definition.pluginId}@${definition.version}') — " + + "declared outputs: ${declaredOutputs.joinToString()}" + ) + } + } + } + + private fun resolveDefinition( + externalPluginConfigurationId: UUID?, + reference: PluginConfigurationReference, + ): ExternalPluginDefinition? { + return when (reference.type) { + FIXED -> externalPluginConfigurationId + ?.let { configId -> configurationRepository.findById(configId).orElse(null) } + ?.let { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + + BUILDING_BLOCK -> { + val pluginId = reference.pluginDefinitionKey + val version = reference.pluginDefinitionVersion + if (pluginId == null || version == null) { + null + } else { + definitionRepository.findByPluginIdAndVersion(pluginId, version) + } + } + } + } + + /** + * `null` means the manifest doesn't declare `actions` at all (lenient); an empty list means the + * action key wasn't found, or was found without an `outputs` array — both treated as "no + * declared outputs" (reject any mapping). + */ + private fun declaredActionOutputs(definition: ExternalPluginDefinition, actionKey: String): List? { + val actions = definition.manifestJson?.get("actions") ?: return null + if (!actions.isArray) { + return null + } + val action = actions.firstOrNull { it.get("key")?.asText() == actionKey } ?: return emptyList() + val outputs = action.get("outputs") ?: return emptyList() + if (!outputs.isArray) { + return emptyList() + } + return outputs.mapNotNull { it.asText(null) } + } + + companion object { + const val ISSUE_TYPE = "external-plugin-process-link" + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt new file mode 100644 index 0000000000..d679fe6e97 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt @@ -0,0 +1,356 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.exception.ExternalPluginActionFailedException +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.logging.withLoggingContext +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.PluginActionResultHandler +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.event.OperatonExecutionEvent +import com.ritense.valueresolver.ValueResolverService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.util.UUID +@Component +@SkipComponentScan +class ExternalPluginServiceTaskStartListener( + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val configurationService: ExternalPluginConfigurationService, + private val definitionService: ExternalPluginDefinitionService, + private val hostService: ExternalPluginHostService, + private val hostClient: ExternalPluginHostClient, + private val valueResolverService: ValueResolverService, + private val objectMapper: ObjectMapper, + private val pluginActionResultHandler: PluginActionResultHandler, + private val buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver? = null, +) { + + @EventListener( + condition = """#event.delegateExecution.bpmnModelElementInstance != null + && #event.delegateExecution.bpmnModelElementInstance.elementType.typeName == T(org.operaton.bpm.engine.ActivityTypes).TASK_SERVICE + && #event.eventName == T(org.operaton.bpm.engine.delegate.ExecutionListener).EVENTNAME_START""" + ) + fun notify(event: OperatonExecutionEvent) { + val execution = event.delegateExecution + withLoggingContext("com.ritense.document.domain.impl.JsonSchemaDocument", execution.processBusinessKey) { + processLinkRepository.findByProcessDefinitionIdAndActivityIdAndActivityType( + execution.processDefinitionId, + execution.currentActivityId, + ActivityTypeWithEventName.SERVICE_TASK_START, + ).forEach { processLink -> invoke(execution, processLink) } + } + } + + private fun invoke(execution: DelegateExecution, processLink: ExternalPluginProcessLink) { + val configurationId = resolveConfigurationId(execution, processLink) + val configuration = configurationService.get(configurationId) + val definition = definitionService.get(configuration.definitionId) + validateResolvedDefinition(processLink, definition) + requireAcceptedContent(definition, processLink) + val host = hostService.get(definition.hostId) + val hostSecret = hostService.decryptedSecret(host) + + val resolvedProperties = resolveActionProperties(execution, processLink) + val payload = buildPayload(execution, processLink, configuration, resolvedProperties) + + // Version always comes from the resolved configuration's definition, never from the link's + // (design-time-only) reference — invoking v1 plugin code with a v2 config's token must be + // impossible. See PluginConfigurationReference / D1. + val response = hostClient.invokeAction( + baseUrl = host.baseUrl, + pluginId = definition.pluginId, + version = definition.version, + actionKey = processLink.actionKey, + payload = payload, + hostSecret = hostSecret, + ) + + when { + response.status in 200..299 -> { + validateDeclaredOutputs(definition, processLink, response.body) + applySuccess(execution, processLink, response.body) + } + + else -> throw actionFailed(response, definition, processLink) + } + } + + /** + * Enforces the manifest's result contract at runtime: when the resolved definition's manifest + * declares `outputs` for the invoked action, the response's `result` must contain every + * declared key. A key may hold JSON null — null is a legitimate value — but an absent key means + * the plugin broke its own contract (typically a value dropped during serialization, e.g. an + * `undefined` in a JS plugin), which would otherwise surface only as a silently skipped result + * mapping. Validated before anything (variables or mappings) is applied, so a contract + * violation fails the invocation without partial writes. + */ + private fun validateDeclaredOutputs( + definition: ExternalPluginDefinition, + processLink: ExternalPluginProcessLink, + body: JsonNode?, + ) { + val declaredOutputs = declaredOutputs(definition, processLink.actionKey) + if (declaredOutputs.isEmpty()) { + return + } + + val result = body?.get("result") + val missingKeys = if (result != null && result.isObject) { + declaredOutputs.filterNot { result.has(it) } + } else { + declaredOutputs + } + if (missingKeys.isNotEmpty()) { + val message = "External plugin '${definition.pluginId}@${definition.version}' action " + + "'${processLink.actionKey}' declares outputs $declaredOutputs in its manifest, but its " + + "result is missing key(s) $missingKeys. Every declared output must be returned; " + + "returning null for a key is allowed." + logger.warn { message } + throw ExternalPluginActionFailedException("RESULT_CONTRACT_VIOLATION", message) + } + } + + private fun declaredOutputs(definition: ExternalPluginDefinition, actionKey: String): List { + val actions = definition.manifestJson?.get("actions") ?: return emptyList() + if (!actions.isArray) return emptyList() + val action = actions.firstOrNull { it.get("key")?.asText() == actionKey } ?: return emptyList() + val outputs = action.get("outputs") ?: return emptyList() + if (!outputs.isArray) return emptyList() + return outputs.mapNotNull { if (it.isTextual) it.asText() else null } + } + + /** + * `FIXED` links carry the configuration id directly. `BUILDING_BLOCK` links carry a + * `pluginId`/version pair (design-time metadata, D1) and are resolved through the shared + * [BuildingBlockPluginConfigurationResolver] SPI (already in `:backend:plugin`; no new module + * dependency) using the namespaced key `external-plugin:@` (D2) — the same + * `pluginConfigurationMappings` map the embedded system's `PluginService.invoke` reads, just + * under a distinct key so the two systems can never collide. + */ + private fun resolveConfigurationId(execution: DelegateExecution, processLink: ExternalPluginProcessLink): UUID { + return when (processLink.pluginConfigurationReference.type) { + PluginConfigurationReferenceType.FIXED -> requireNotNull(processLink.externalPluginConfigurationId) { + "External plugin process link '${processLink.id}' has no configuration id" + } + + PluginConfigurationReferenceType.BUILDING_BLOCK -> { + val pluginId = processLink.pluginConfigurationReference.pluginDefinitionKey + ?: throw IllegalStateException( + "External plugin process link '${processLink.id}' has a BUILDING_BLOCK reference " + + "without a pluginDefinitionKey" + ) + val version = processLink.pluginConfigurationReference.pluginDefinitionVersion + ?: throw IllegalStateException( + "External plugin process link '${processLink.id}' has a BUILDING_BLOCK reference " + + "without a pluginDefinitionVersion" + ) + val resolver = buildingBlockPluginConfigurationResolver + ?: throw IllegalStateException( + "Building block plugin configuration resolver is not available — cannot resolve " + + "external plugin process link '${processLink.id}'" + ) + + val mappingKey = buildingBlockMappingKey(pluginId, version) + resolver.resolve(execution, mappingKey) + ?: resolver.resolveByKeyPrefix(execution, buildingBlockMappingKeyPrefix(pluginId))?.also { + // Version-tolerant fallback: no mapping for the exact pinned version, but one + // exists for another version of the same plugin. The resolved configuration's + // version wins at runtime (D1), mirroring how a mismatched version is accepted + // for FIXED links; validateResolvedDefinition surfaces the mismatch as a warning. + logger.warn { + "No building-block plugin configuration mapping for '$mappingKey' (process " + + "link '${processLink.id}'); using a mapping for a different version of " + + "external plugin '$pluginId'." + } + } + ?: throw IllegalStateException( + "No plugin configuration mapping provided for external plugin '$mappingKey' " + + "(process link '${processLink.id}')" + ) + } + } + } + + /** + * Version mismatches between the (design-time) reference and the resolved configuration's + * definition are allowed — the resolved definition's version always wins at runtime (D1) — but + * are surfaced as a warning since they usually mean the BB mapping was pinned to a version that + * later moved. A `pluginId` mismatch is not recoverable: it means the mapped configuration + * belongs to an entirely different plugin, so the action would be invoked against unrelated code. + * Regardless of reference type, the resolved definition's manifest must still declare the + * action key the link invokes — a `BUILDING_BLOCK` mapping can point at a configuration whose + * definition no longer exposes this action. + */ + private fun validateResolvedDefinition(processLink: ExternalPluginProcessLink, definition: ExternalPluginDefinition) { + val reference = processLink.pluginConfigurationReference + val expectedPluginId = reference.pluginDefinitionKey + + if (expectedPluginId != null) { + require(definition.pluginId == expectedPluginId) { + "External plugin process link '${processLink.id}' expects plugin '$expectedPluginId' " + + "but resolved configuration belongs to plugin '${definition.pluginId}'" + } + + val expectedVersion = reference.pluginDefinitionVersion + if (expectedVersion != null && expectedVersion != definition.version) { + logger.warn { + "External plugin process link '${processLink.id}' reference pins version " + + "'$expectedVersion' for plugin '$expectedPluginId', but the resolved configuration " + + "uses version '${definition.version}' — proceeding with the resolved configuration's version" + } + } + } + + require(definitionDeclaresActionKey(definition, processLink.actionKey)) { + "External plugin process link '${processLink.id}' invokes action '${processLink.actionKey}' " + + "which plugin '${definition.pluginId}@${definition.version}' does not declare in its manifest" + } + } + + private fun definitionDeclaresActionKey(definition: ExternalPluginDefinition, actionKey: String): Boolean { + val actions = definition.manifestJson?.get("actions") ?: return true + if (!actions.isArray) return true + return actions.any { it.get("key")?.asText() == actionKey } + } + + private fun buildingBlockMappingKey(pluginId: String, version: String) = "external-plugin:$pluginId@$version" + + /** Version-agnostic prefix of [buildingBlockMappingKey]: matches a mapping for any version of the plugin. */ + private fun buildingBlockMappingKeyPrefix(pluginId: String) = "external-plugin:$pluginId@" + + private fun resolveActionProperties(execution: DelegateExecution, processLink: ExternalPluginProcessLink): ObjectNode { + val rawProperties = processLink.actionProperties ?: objectMapper.createObjectNode() + val keysToResolve = mutableListOf() + rawProperties.fields().forEachRemaining { (_, value) -> + // Only send values through the resolver when a resolver factory actually supports the + // prefix. A literal that merely contains a colon (e.g. "https://example.com") is passed + // through untouched instead of tripping the resolver on an unknown prefix. + if (value.isTextual && valueResolverService.supportsValue(value.asText())) { + keysToResolve += value.asText() + } + } + val resolved = if (keysToResolve.isEmpty()) { + emptyMap() + } else { + valueResolverService.resolveValues(execution.processInstanceId, execution, keysToResolve) + } + + val output = objectMapper.createObjectNode() + rawProperties.fields().forEachRemaining { (key, value) -> + if (value.isTextual && resolved.containsKey(value.asText())) { + output.set(key, objectMapper.valueToTree(resolved[value.asText()])) + } else { + output.set(key, value) + } + } + return output + } + + private fun buildPayload( + execution: DelegateExecution, + processLink: ExternalPluginProcessLink, + configuration: ExternalPluginConfiguration, + properties: ObjectNode, + ): ObjectNode { + val payload = objectMapper.createObjectNode() + payload.put("configurationId", configuration.id.toString()) + payload.put("processInstanceId", execution.processInstanceId) + payload.put("activityId", execution.currentActivityId) + execution.processBusinessKey?.let { payload.put("documentId", it) } + payload.set("properties", properties) + return payload + } + + /** + * `variables` and `result` are separate channels that never interfere: `variables` keeps its + * existing process-variable behavior unconditionally, while `result` only feeds the configured + * [PluginActionResultMapping][com.ritense.plugin.domain.PluginActionResultMapping]s. Plugins built + * against an older SDK simply omit `result` and have nothing to map. + */ + private fun applySuccess(execution: DelegateExecution, processLink: ExternalPluginProcessLink, body: JsonNode?) { + val variables = body?.get("variables") + if (variables != null && variables.isObject) { + variables.fields().forEachRemaining { (key, value) -> + execution.setVariable(key, objectMapper.treeToValue(value, Any::class.java)) + } + } + + if (processLink.actionResultMappings.isNotEmpty()) { + pluginActionResultHandler.handle(execution, body?.get("result"), processLink.actionResultMappings) + } + } + + /** + * Turns a non-2xx host response into a failure that surfaces on the job incident with the + * plugin's real error code and message. See [ExternalPluginActionFailedException] for why this + * is a plain exception and not a BpmnError. + */ + /** + * A definition whose host package changed after acceptance must not be invoked: what would run + * is not what the admin accepted. Fails the invocation (surfacing as a process incident) until + * an admin re-accepts the new content. + */ + private fun requireAcceptedContent(definition: ExternalPluginDefinition, processLink: ExternalPluginProcessLink) { + if (definition.requiresReacceptance) { + val message = "External plugin '${definition.pluginId}@${definition.version}' action " + + "'${processLink.actionKey}' was not invoked: the plugin package changed on its host " + + "and awaits re-acceptance by an administrator" + logger.warn { message } + throw ExternalPluginActionFailedException(CONTENT_CHANGED_ERROR_CODE, message) + } + } + + private fun actionFailed( + response: ExternalPluginHostClient.ActionResponse, + definition: ExternalPluginDefinition, + processLink: ExternalPluginProcessLink, + ): ExternalPluginActionFailedException { + val errorCode = response.body?.get("errorCode")?.asText() + ?: "EXTERNAL_PLUGIN_${response.status}" + val detail = (response.body?.get("errorMessage") ?: response.body?.get("message"))?.asText() + val message = buildString { + append("External plugin '${definition.pluginId}' action '${processLink.actionKey}' ") + append("failed with status ${response.status} (code: $errorCode)") + if (!detail.isNullOrBlank()) append(": $detail") + } + logger.warn { message } + return ExternalPluginActionFailedException(errorCode, message) + } + + companion object { + /** Error code raised when an invocation is blocked pending content re-acceptance. */ + const val CONTENT_CHANGED_ERROR_CODE = "EXTERNAL_PLUGIN_CONTENT_CHANGED" + + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginSupportedProcessLinkTypeHandler.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginSupportedProcessLinkTypeHandler.kt new file mode 100644 index 0000000000..5ead3a381b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginSupportedProcessLinkTypeHandler.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLinkType +import com.ritense.processlink.domain.SupportedProcessLinkTypeHandler + +class ExternalPluginSupportedProcessLinkTypeHandler : SupportedProcessLinkTypeHandler { + + private val supportedActivityTypes = listOf( + ActivityTypeWithEventName.SERVICE_TASK_START, + ) + + override fun getProcessLinkType(activityType: String): ProcessLinkType? { + if (supportedActivityTypes.contains(ActivityTypeWithEventName.fromValue(activityType))) { + return ProcessLinkType(PROCESS_LINK_TYPE, true) + } + return null + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandler.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandler.kt new file mode 100644 index 0000000000..4a4937b9e7 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandler.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.service.ProcessLinkActivityHandler +import com.ritense.processlink.web.rest.dto.ProcessLinkActivityResult +import com.ritense.valtimo.operaton.domain.OperatonTask +import java.util.UUID + +/** + * Handles opening a user task linked to an external plugin `task-form` bundle. It resolves the + * bundle URL and returns an `external-plugin-task-form` [ProcessLinkActivityResult] carrying the + * iframe render instructions; the frontend embeds the plugin iframe and the plugin completes the task + * itself under the downscoped user token. There is no server-side action to invoke — this is purely a + * render descriptor, mirroring the URL/UI-component handlers rather than the service-task listener. + */ +class ExternalPluginTaskFormProcessLinkActivityHandler( + private val bundleUrlResolver: ExternalPluginBundleUrlResolver, +) : ProcessLinkActivityHandler { + + override fun supports(processLink: ProcessLink): Boolean { + return processLink is ExternalPluginTaskFormProcessLink + } + + override fun openTask( + task: OperatonTask, + processLink: ProcessLink, + ): ProcessLinkActivityResult { + processLink as ExternalPluginTaskFormProcessLink + return ProcessLinkActivityResult( + processLink.id, + ACTIVITY_RESULT_TYPE, + task.assignee, + task.dueDate, + resultProperties( + processLink, + taskId = task.id, + processInstanceId = task.processInstance?.id, + documentId = task.processInstance?.businessKey, + ), + ) + } + + override fun getStartEventObject( + processDefinitionId: String, + documentId: UUID?, + documentDefinitionName: String?, + processLink: ProcessLink, + ): ProcessLinkActivityResult { + processLink as ExternalPluginTaskFormProcessLink + return ProcessLinkActivityResult( + processLink.id, + ACTIVITY_RESULT_TYPE, + null, + null, + resultProperties( + processLink, + taskId = null, + processInstanceId = null, + documentId = documentId?.toString(), + ), + ) + } + + private fun resultProperties( + processLink: ExternalPluginTaskFormProcessLink, + taskId: String?, + processInstanceId: String?, + documentId: String?, + ): ExternalPluginTaskFormResultProperties { + val bundleUrl = bundleUrlResolver.resolve( + processLink.externalPluginConfigurationId, + TASK_FORM_BUNDLE_TYPE, + processLink.bundleKey, + ) + return ExternalPluginTaskFormResultProperties( + bundleUrl = bundleUrl, + configurationId = processLink.externalPluginConfigurationId, + bundleKey = processLink.bundleKey, + context = ExternalPluginTaskFormContext( + taskId = taskId, + processInstanceId = processInstanceId, + documentId = documentId, + pluginConfigurationId = processLink.externalPluginConfigurationId.toString(), + ), + ) + } + + companion object { + const val ACTIVITY_RESULT_TYPE = "external-plugin-task-form" + private const val TASK_FORM_BUNDLE_TYPE = "task-form" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt new file mode 100644 index 0000000000..08677a9e0a --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkDeployDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkExportResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField +import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto +import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto +import com.ritense.valtimo.contract.BlueprintId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import org.springframework.context.ApplicationEventPublisher +import java.util.UUID + +/** + * The task-form link is always a `FIXED` reference today; `pluginDefinitionKey`/`pluginVersion` are + * derived from + * [ExternalPluginTaskFormProcessLinkCreateRequestDto.externalPluginConfigurationId]'s definition at + * save time — mirrors [ExternalPluginProcessLinkMapper]'s `FIXED` handling. The frontend keeps + * sending `pluginVersion` for backward compatibility, but it is only used as a dangling-import + * fallback when the configuration can no longer be resolved. + */ +class ExternalPluginTaskFormProcessLinkMapper( + objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val processLinkRepository: ExternalPluginTaskFormProcessLinkRepository, +) : ProcessLinkMapper { + + init { + objectMapper.registerSubtypes( + ExternalPluginTaskFormProcessLinkCreateRequestDto::class.java, + ExternalPluginTaskFormProcessLinkUpdateRequestDto::class.java, + ExternalPluginTaskFormProcessLinkResponseDto::class.java, + ExternalPluginTaskFormProcessLinkDeployDto::class.java, + ExternalPluginTaskFormProcessLinkExportResponseDto::class.java, + ) + } + + override fun supportsProcessLinkType(processLinkType: String) = processLinkType == PROCESS_LINK_TYPE + + override fun toProcessLinkResponseDto(processLink: ProcessLink): ProcessLinkResponseDto { + processLink as ExternalPluginTaskFormProcessLink + return ExternalPluginTaskFormProcessLinkResponseDto( + id = processLink.id, + processDefinitionId = processLink.processDefinitionId, + activityId = processLink.activityId, + activityType = processLink.activityType, + externalPluginConfigurationId = processLink.externalPluginConfigurationId, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + bundleKey = processLink.bundleKey, + ) + } + + override fun toNewProcessLink(createRequestDto: ProcessLinkCreateRequestDto, blueprintId: BlueprintId?): ProcessLink { + createRequestDto as ExternalPluginTaskFormProcessLinkCreateRequestDto + return ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = createRequestDto.processDefinitionId, + activityId = createRequestDto.activityId, + activityType = createRequestDto.activityType, + externalPluginConfigurationId = createRequestDto.externalPluginConfigurationId, + pluginConfigurationReference = createReference( + createRequestDto.externalPluginConfigurationId, + createRequestDto.pluginVersion, + ), + bundleKey = createRequestDto.bundleKey, + ) + } + + override fun toUpdatedProcessLink( + processLinkToUpdate: ProcessLink, + updateRequestDto: ProcessLinkUpdateRequestDto, + blueprintId: BlueprintId?, + ): ProcessLink { + updateRequestDto as ExternalPluginTaskFormProcessLinkUpdateRequestDto + assert(processLinkToUpdate.id == updateRequestDto.id) + return ExternalPluginTaskFormProcessLink( + id = updateRequestDto.id, + processDefinitionId = processLinkToUpdate.processDefinitionId, + activityId = processLinkToUpdate.activityId, + activityType = processLinkToUpdate.activityType, + externalPluginConfigurationId = updateRequestDto.externalPluginConfigurationId, + pluginConfigurationReference = createReference( + updateRequestDto.externalPluginConfigurationId, + updateRequestDto.pluginVersion, + ), + bundleKey = updateRequestDto.bundleKey, + ) + } + + override fun toProcessLinkCreateRequestDto(deployDto: ProcessLinkDeployDto, blueprintId: BlueprintId?): ProcessLinkCreateRequestDto { + deployDto as ExternalPluginTaskFormProcessLinkDeployDto + return ExternalPluginTaskFormProcessLinkCreateRequestDto( + processDefinitionId = deployDto.processDefinitionId, + activityId = deployDto.activityId, + activityType = deployDto.activityType, + externalPluginConfigurationId = deployDto.externalPluginConfigurationId, + pluginVersion = deployDto.pluginVersion, + bundleKey = deployDto.bundleKey, + ) + } + + override fun toProcessLinkUpdateRequestDto( + deployDto: ProcessLinkDeployDto, + existingProcessLinkId: UUID, + blueprintId: BlueprintId?, + ): ProcessLinkUpdateRequestDto { + deployDto as ExternalPluginTaskFormProcessLinkDeployDto + return ExternalPluginTaskFormProcessLinkUpdateRequestDto( + id = existingProcessLinkId, + externalPluginConfigurationId = deployDto.externalPluginConfigurationId, + pluginVersion = deployDto.pluginVersion, + bundleKey = deployDto.bundleKey, + ) + } + + override fun toProcessLinkExportResponseDto(processLink: ProcessLink): ProcessLinkExportResponseDto { + processLink as ExternalPluginTaskFormProcessLink + return ExternalPluginTaskFormProcessLinkExportResponseDto( + activityId = processLink.activityId, + activityType = processLink.activityType, + externalPluginConfigurationId = processLink.externalPluginConfigurationId, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + bundleKey = processLink.bundleKey, + ) + } + + /** + * `externalPluginConfigurationId` is non-nullable on this link's deploy DTO (always `FIXED`), + * so a mapping value of `null` (admin chose to leave it dangling) is left as the original, + * unmapped id rather than nulled out — nulling it would fail deserialization outright. + */ + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "externalPluginConfigurationId", mappings, allowNull = false) + } + + /** + * Mirrors [ExternalPluginProcessLinkMapper.afterImport], but publishes under its **own** + * [ISSUE_TYPE]. The two link kinds inspect disjoint repositories, so sharing a single issue type + * let one mapper's "resolved" verdict clobber the other's "detected" verdict (last writer in the + * mapper loop wins). Each owning its own type removes that coupling entirely. Unlike the + * service-task link, the task-form link's `externalPluginConfigurationId` is never nullable — it + * always references a `FIXED` configuration — so "dangling" here only means the referenced + * configuration no longer exists in the target environment. + */ + override fun afterImport( + caseDefinitionId: CaseDefinitionId, + processDefinitionIds: Set, + applicationEventPublisher: ApplicationEventPublisher + ) { + val allLinks = processDefinitionIds.flatMap { pdId -> processLinkRepository.findByProcessDefinitionId(pdId) } + + val hasIssue = allLinks.any { link -> !configurationRepository.existsById(link.externalPluginConfigurationId) } + + if (hasIssue) { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } else { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } + } + + /** + * Always `FIXED` today: `pluginDefinitionKey`/`pluginVersion` are derived from + * [externalPluginConfigurationId]'s definition, falling back to the dto-supplied [pluginVersion] + * only when the configuration can no longer be resolved (dangling import). + */ + private fun createReference( + externalPluginConfigurationId: UUID, + pluginVersion: String, + ): PluginConfigurationReference { + val definition = configurationRepository.findById(externalPluginConfigurationId) + .map { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + .orElse(null) + return PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = definition?.pluginId, + pluginDefinitionVersion = definition?.version ?: pluginVersion, + ) + } + + companion object { + const val ISSUE_TYPE = "external-plugin-task-form" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormResultProperties.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormResultProperties.kt new file mode 100644 index 0000000000..9bd7089622 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormResultProperties.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import java.util.UUID + +/** + * The `properties` payload of the `external-plugin-task-form` activity result returned to the + * frontend when a user opens a task backed by an external plugin task-form. It carries everything the + * frontend needs to render the plugin's iframe: the resolved [bundleUrl], the [configurationId] (to + * mint the downscoped user token and derive the plugin `/data` URL), the optional [bundleKey], and + * the [context] the iframe passes back to the plugin (notably the [ExternalPluginTaskFormContext.taskId] + * the plugin completes). + */ +data class ExternalPluginTaskFormResultProperties( + val bundleUrl: String?, + val configurationId: UUID, + val bundleKey: String?, + val context: ExternalPluginTaskFormContext, +) + +/** + * Opaque per-task context handed to the plugin iframe (and forwarded to the plugin's `handle_request` + * submit handler). `taskId` is authoritative — the plugin completes exactly this task, never one the + * browser names in a request body. + */ +data class ExternalPluginTaskFormContext( + val taskId: String?, + val processInstanceId: String?, + val documentId: String?, + val pluginConfigurationId: String, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt new file mode 100644 index 0000000000..09fc7e4a2e --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.authorization.AuthorizationService +import com.ritense.authorization.request.EntityAuthorizationRequest +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.document.domain.impl.request.ModifyDocumentRequest +import com.ritense.document.service.impl.JsonSchemaDocumentService +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormSubmissionResult +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndCompleteTaskRequest +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processdocument.resolver.CaseDocumentJsonValueResolverFactory.Companion.PREFIX as DOC_PREFIX +import com.ritense.processlink.domain.ProcessLink +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.operaton.authorization.OperatonTaskActionProvider +import com.ritense.valtimo.operaton.domain.OperatonTask +import com.ritense.valtimo.service.OperatonTaskService +import com.ritense.valueresolver.ProcessVariableValueResolverFactory.Companion.PREFIX as PV_PREFIX +import com.ritense.valueresolver.ValueResolverService +import com.ritense.valueresolver.ValueResolverServiceImpl.Companion.DELIMITER +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Completes a user task backed by an external-plugin `task-form` bundle the *same way* GZAC completes + * every other form: it categorises the submitted values by value-resolver prefix and dispatches a + * [ModifyDocumentAndCompleteTaskRequest] through [ProcessDocumentService]. The task is completed + * server-side, as the logged-in user (PBAC applies), and value resolvers / document updates / the + * `TaskCompleted` outbox event all fire — no plugin code, permission grant or user token needed for + * the common case. + * + * Three capability levels are supported (see the sample plugin): + * - **Level 0 — pure form.** The submission map arrives with value-resolver-prefixed keys + * (`pv:approved`, `doc:/reviewComment`); this service categorises and completes. Unprefixed keys + * are treated as process variables. + * - **Level 1 — transform / validate hook.** When the manifest's `task-form` bundle declares + * `submitHandler: true`, this service first calls the plugin's `handle_submit` export + * (server-to-server, HMAC, on the same rails as actions). The plugin returns `{variables, + * documentContent?}` (which become the effective submission) or `{status: "error", …, fieldErrors}` + * which is surfaced to the form without completing the task. + * - **Level 2 — full custom.** Untouched: a plugin may still drive completion itself through + * `request()` + `gzacApi.asUser`. That path does not use this service. + */ +@Transactional +@SkipComponentScan +class ExternalPluginTaskFormSubmissionService( + private val processLinkService: ProcessLinkService, + private val configurationService: ExternalPluginConfigurationService, + private val definitionService: ExternalPluginDefinitionService, + private val hostService: ExternalPluginHostService, + private val hostClient: ExternalPluginHostClient, + private val processDocumentService: ProcessDocumentService, + private val documentService: JsonSchemaDocumentService, + private val operatonTaskService: OperatonTaskService, + private val authorizationService: AuthorizationService, + private val valueResolverService: ValueResolverService, + private val objectMapper: ObjectMapper, +) { + + fun handleSubmission( + processLinkId: UUID, + submission: JsonNode, + documentId: String?, + taskInstanceId: String?, + ): ExternalPluginTaskFormSubmissionResult { + // Validate up front: without a task there is nothing to complete and — crucially — no task + // to check the COMPLETE permission on, so no hook may run either. + requireNotNull(taskInstanceId) { "A task-form submission requires a taskInstanceId" } + val processLink = processLinkService.getProcessLink( + processLinkId, + ExternalPluginTaskFormProcessLink::class.java, + ) + val task = requireCompleteTaskPermission(taskInstanceId) + + // The host serves the bundle (and would run the submit hook) from its *current* package — + // when that package no longer matches the accepted content, nothing of this plugin may + // execute, so the submission is refused rather than the hook silently skipped. + val definition = definitionService.get( + configurationService.get(processLink.externalPluginConfigurationId).definitionId + ) + if (definition.requiresReacceptance) { + logger.warn { + "Refusing task-form submission for external plugin " + + "'${definition.pluginId}@${definition.version}': the plugin package changed on its " + + "host and awaits re-acceptance" + } + return ExternalPluginTaskFormSubmissionResult( + errors = listOf( + "The plugin '${definition.pluginId}@${definition.version}' changed on its host and " + + "awaits re-acceptance by an administrator" + ) + ) + } + + // Level 1 — hand the raw submission to the plugin to validate/transform first, if declared. + val effectiveSubmission = when (val hook = resolveSubmitHook(processLink)) { + null -> submission + else -> { + val response = invokeHook(hook, processLink, submission, task, documentId) + if (response.status !in 200..299) { + return hookRejection(response.body) + } + normalizeHookOutput(response.body) + } + } + + return complete(effectiveSubmission, documentId, taskInstanceId) + } + + /** + * Loads the task and asserts the caller may complete it. Mirrors the URL/form submission services + * so a plugin task-form is governed by exactly the same COMPLETE permission as any other form. + */ + private fun requireCompleteTaskPermission(taskInstanceId: String): OperatonTask { + val task = operatonTaskService.findTaskById(taskInstanceId) + authorizationService.requirePermission( + EntityAuthorizationRequest( + OperatonTask::class.java, + OperatonTaskActionProvider.COMPLETE, + task, + ) + ) + return task + } + + private fun complete( + submission: JsonNode, + documentId: String?, + taskInstanceId: String?, + ): ExternalPluginTaskFormSubmissionResult { + val categorized = categorize(submission) + + if (documentId == null) { + // No case document (non-case-bound process): complete with process variables only. The + // value-resolver values (doc:/case: …) have no document to write to and are ignored. + requireNotNull(taskInstanceId) { "A task-form submission requires a taskInstanceId or a documentId" } + operatonTaskService.completeTaskWithFormData(taskInstanceId, categorized.processVariables) + return ExternalPluginTaskFormSubmissionResult() + } + + requireNotNull(taskInstanceId) { "A task-form user-task submission requires a taskInstanceId" } + val document = runWithoutAuthorization { documentService.get(documentId) } + val request = ModifyDocumentAndCompleteTaskRequest( + ModifyDocumentRequest(document.id().toString(), objectMapper.createObjectNode()), + taskInstanceId, + ).withProcessVars(categorized.processVariables) + + val result = processDocumentService.dispatch( + request.withAdditionalModifications { modified: JsonSchemaDocument -> + if (categorized.valueResolverValues.isNotEmpty()) { + valueResolverService.handleValues(modified.id.id, categorized.valueResolverValues) + } + } + ) + + return if (result.errors().isNotEmpty()) { + ExternalPluginTaskFormSubmissionResult(errors = result.errors().map { it.asString() }) + } else { + ExternalPluginTaskFormSubmissionResult( + documentId = result.resultingDocument().orElseThrow().id().toString() + ) + } + } + + /** + * Splits the submission map by value-resolver prefix: + * - `pv:foo` (and unprefixed keys) → process variables (`pv:` stripped); + * - anything else with a `:` prefix (`doc:/x`, `case:x`, custom resolvers) → value-resolver + * values applied against the resulting document, exactly like a form.io submission. + */ + private fun categorize(submission: JsonNode): Categorized { + val processVariables = mutableMapOf() + val valueResolverValues = mutableMapOf() + submission.fields().forEach { (key, valueNode) -> + val value: Any? = if (valueNode.isNull) null else objectMapper.treeToValue(valueNode, Any::class.java) + when { + key.startsWith("$PV_PREFIX$DELIMITER") -> value?.let { processVariables[key.substringAfter(DELIMITER)] = it } + !key.contains(DELIMITER) -> value?.let { processVariables[key] = it } + else -> valueResolverValues[key] = value + } + } + return Categorized(processVariables, valueResolverValues) + } + + // ---- Level 1 hook plumbing ---- + + private fun resolveSubmitHook(processLink: ExternalPluginTaskFormProcessLink): SubmitHook? { + val configuration = configurationService.get(processLink.externalPluginConfigurationId) + val definition = definitionService.get(configuration.definitionId) + val bundle = findTaskFormBundle(definition, processLink.bundleKey) ?: return null + if (bundle.get("submitHandler")?.asBoolean() != true) return null + // The plugin registers its handler under the bundle key (`submit("review", …)`), so the key + // is required to route the hook. + val submitKey = bundle.get("key")?.asText() ?: return null + val host = hostService.get(definition.hostId) + // Version always comes from the resolved configuration's definition, never from the link's + // (design-time-only) reference — same rule as ExternalPluginServiceTaskStartListener. See + // PluginConfigurationReference / D1. + return SubmitHook( + baseUrl = host.baseUrl, + pluginId = definition.pluginId, + version = definition.version, + submitKey = submitKey, + hostSecret = hostService.decryptedSecret(host), + ) + } + + private fun findTaskFormBundle(definition: ExternalPluginDefinition, bundleKey: String?): JsonNode? { + val bundles = definition.manifestJson?.get("frontendBundles") ?: return null + if (!bundles.isArray) return null + val typed = bundles.filter { it.get("type")?.asText() == TASK_FORM_BUNDLE_TYPE } + return when { + bundleKey != null -> typed.firstOrNull { it.get("key")?.asText() == bundleKey } + else -> typed.singleOrNull() ?: typed.firstOrNull() + } + } + + private fun invokeHook( + hook: SubmitHook, + processLink: ExternalPluginTaskFormProcessLink, + submission: JsonNode, + task: OperatonTask?, + documentId: String?, + ): ExternalPluginHostClient.ActionResponse { + val payload = objectMapper.createObjectNode().apply { + put("configurationId", processLink.externalPluginConfigurationId.toString()) + task?.id?.let { put("taskId", it) } + task?.processInstance?.id?.let { put("processInstanceId", it) } + documentId?.let { put("documentId", it) } + set("submission", submission) + } + return hostClient.invokeSubmit( + baseUrl = hook.baseUrl, + pluginId = hook.pluginId, + version = hook.version, + submitKey = hook.submitKey, + payload = payload, + hostSecret = hook.hostSecret, + ) + } + + /** + * Turns a Level 1 hook's `{variables, documentContent}` output into the same value-resolver- + * prefixed submission map [complete] categorises, so both levels flow through one code path. + */ + private fun normalizeHookOutput(body: JsonNode?): ObjectNode { + val normalized = objectMapper.createObjectNode() + body?.get("variables")?.takeIf { it.isObject }?.fields()?.forEach { (key, value) -> + normalized.set("$PV_PREFIX$DELIMITER$key", value) + } + body?.get("documentContent")?.takeIf { it.isObject }?.fields()?.forEach { (path, value) -> + val pointer = if (path.startsWith("/")) path else "/$path" + normalized.set("$DOC_PREFIX$DELIMITER$pointer", value) + } + return normalized + } + + private fun hookRejection(body: JsonNode?): ExternalPluginTaskFormSubmissionResult { + val fieldErrors = body?.get("fieldErrors")?.takeIf { it.isObject } + ?.let { node -> node.fields().asSequence().associate { (k, v) -> k to v.asText() } } + ?: emptyMap() + val message = (body?.get("errorMessage") ?: body?.get("message"))?.asText() + val errors = buildList { + if (message != null) add(message) + if (isEmpty() && fieldErrors.isEmpty()) add("Submission was rejected by the plugin") + } + logger.info { "External plugin task-form submit hook rejected the submission (fieldErrors=${fieldErrors.keys})" } + return ExternalPluginTaskFormSubmissionResult(errors = errors, fieldErrors = fieldErrors) + } + + private data class Categorized( + val processVariables: Map, + val valueResolverValues: Map, + ) + + private data class SubmitHook( + val baseUrl: String, + val pluginId: String, + val version: String, + val submitKey: String, + val hostSecret: String, + ) + + companion object { + private const val TASK_FORM_BUNDLE_TYPE = "task-form" + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandler.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandler.kt new file mode 100644 index 0000000000..89feb7297d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandler.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLinkType +import com.ritense.processlink.domain.SupportedProcessLinkTypeHandler + +/** + * Declares the external plugin `task-form` link type as supported for user tasks. Unlike the + * service-task action type ([ExternalPluginSupportedProcessLinkTypeHandler]) this handles + * `USER_TASK_CREATE` — the activity type an operator configures when they want a plugin-provided + * form to render for a user task. + */ +class ExternalPluginTaskFormSupportedProcessLinkTypeHandler : SupportedProcessLinkTypeHandler { + + private val supportedActivityTypes = listOf( + ActivityTypeWithEventName.USER_TASK_CREATE, + ) + + override fun getProcessLinkType(activityType: String): ProcessLinkType? { + if (supportedActivityTypes.contains(ActivityTypeWithEventName.fromValue(activityType))) { + return ProcessLinkType(PROCESS_LINK_TYPE, true) + } + return null + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/ExternalPluginTaskFormSubmissionResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/ExternalPluginTaskFormSubmissionResource.kt new file mode 100644 index 0000000000..b8cf5425dd --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/ExternalPluginTaskFormSubmissionResource.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web + +import com.fasterxml.jackson.databind.JsonNode +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormSubmissionService +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormSubmissionResult +import com.ritense.logging.LoggableResource +import com.ritense.processlink.domain.ProcessLink +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import com.ritense.valtimo.operaton.domain.OperatonTask +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * Submission endpoint for external-plugin `task-form` process links — the plugin counterpart of + * `FormResource.handleSubmission`. The Angular parent (not the iframe) POSTs the collected data here + * under the logged-in user's session; GZAC completes the task the standard way. The iframe never + * holds a token and never names the task id — the authoritative `taskInstanceId` is a query param the + * parent supplies from the process-link result. + */ +@RestController +@SkipComponentScan +@RequestMapping("/api", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginTaskFormSubmissionResource( + private val submissionService: ExternalPluginTaskFormSubmissionService, +) { + + @EndpointDescription( + en = "Handle an external-plugin task-form submission", + nl = "Formulierinzending voor externe-plugin taakformulier verwerken", + ) + @PostMapping("/v1/process-link/{processLinkId}/external-plugin-task-form/submission") + fun handleSubmission( + @LoggableResource(resourceType = ProcessLink::class) @PathVariable processLinkId: UUID, + @LoggableResource(resourceType = JsonSchemaDocument::class) @RequestParam(required = false) documentId: String?, + @LoggableResource(resourceType = OperatonTask::class) @RequestParam(required = false) taskInstanceId: String?, + @RequestBody submission: JsonNode, + ): ResponseEntity { + val result = submissionService.handleSubmission(processLinkId, submission, documentId, taskInstanceId) + val status = if (result.errors.isEmpty() && result.fieldErrors.isEmpty()) HttpStatus.OK else HttpStatus.BAD_REQUEST + return ResponseEntity.status(status).body(result) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt new file mode 100644 index 0000000000..861228f58c --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto +import java.util.UUID + +/** + * [pluginVersion]/[pluginDefinitionKey] are only required for `BUILDING_BLOCK` references — for + * `FIXED` the mapper derives both from [externalPluginConfigurationId]'s definition at save time, + * mirroring the embedded mapper's definition-key fallback (D1). The frontend keeps sending just the + * config id for `FIXED`. + */ +@JsonTypeName(PROCESS_LINK_TYPE) +data class ExternalPluginProcessLinkCreateRequestDto( + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID? = null, + val actionKey: String, + val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), +) : ProcessLinkCreateRequestDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt new file mode 100644 index 0000000000..1a158f7b4b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ActivityTypeWithEventName +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +class ExternalPluginProcessLinkDeployDto( + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID? = null, + val actionKey: String, + val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), +) : ProcessLinkDeployDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt new file mode 100644 index 0000000000..8f01113978 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +class ExternalPluginProcessLinkExportResponseDto( + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID?, + val actionKey: String, + val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), +) : ProcessLinkExportResponseDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt new file mode 100644 index 0000000000..3112c8f97d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto +import java.util.UUID + +data class ExternalPluginProcessLinkResponseDto( + override val id: UUID, + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID?, + val actionKey: String, + val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), +) : ProcessLinkResponseDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt new file mode 100644 index 0000000000..c130c2e83b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto +import java.util.UUID + +/** + * See [ExternalPluginProcessLinkCreateRequestDto] for why [pluginVersion]/[pluginDefinitionKey] are + * nullable here (only required for `BUILDING_BLOCK`). + */ +@JsonTypeName(PROCESS_LINK_TYPE) +data class ExternalPluginProcessLinkUpdateRequestDto( + override val id: UUID, + val externalPluginConfigurationId: UUID? = null, + val actionKey: String, + val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), +) : ProcessLinkUpdateRequestDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkCreateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkCreateRequestDto.kt new file mode 100644 index 0000000000..fe88cfb335 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkCreateRequestDto.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +data class ExternalPluginTaskFormProcessLinkCreateRequestDto( + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID, + val pluginVersion: String, + val bundleKey: String? = null, +) : ProcessLinkCreateRequestDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkDeployDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkDeployDto.kt new file mode 100644 index 0000000000..e7578381e6 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkDeployDto.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ActivityTypeWithEventName +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +class ExternalPluginTaskFormProcessLinkDeployDto( + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID, + val pluginVersion: String, + val bundleKey: String? = null, +) : ProcessLinkDeployDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt new file mode 100644 index 0000000000..56be3aaa50 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +class ExternalPluginTaskFormProcessLinkExportResponseDto( + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val bundleKey: String? = null, +) : ProcessLinkExportResponseDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt new file mode 100644 index 0000000000..80db4eedce --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto +import java.util.UUID + +data class ExternalPluginTaskFormProcessLinkResponseDto( + override val id: UUID, + override val processDefinitionId: String, + override val activityId: String, + override val activityType: ActivityTypeWithEventName, + val externalPluginConfigurationId: UUID, + val pluginVersion: String? = null, + val bundleKey: String? = null, +) : ProcessLinkResponseDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkUpdateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkUpdateRequestDto.kt new file mode 100644 index 0000000000..7bdb9895eb --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkUpdateRequestDto.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto +import java.util.UUID + +@JsonTypeName(PROCESS_LINK_TYPE) +data class ExternalPluginTaskFormProcessLinkUpdateRequestDto( + override val id: UUID, + val externalPluginConfigurationId: UUID, + val pluginVersion: String, + val bundleKey: String? = null, +) : ProcessLinkUpdateRequestDto { + override val processLinkType: String + get() = PROCESS_LINK_TYPE +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormSubmissionResult.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormSubmissionResult.kt new file mode 100644 index 0000000000..3b1469f21b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormSubmissionResult.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink.web.dto + +/** + * Result of an external-plugin task-form submission, mirroring the shape other process-link + * submission types return (e.g. `URLSubmissionResult`). + * + * - [errors] carries non-field-specific problems (dispatch failures, a plugin's rejection message). + * - [fieldErrors] maps a submitted field to a validation message produced by a Level 1 `submit` + * hook, so the plugin iframe can render them inline. + * - [documentId] is the resulting case document id on success. + * + * A submission is considered failed (HTTP 400) when either [errors] or [fieldErrors] is non-empty. + */ +data class ExternalPluginTaskFormSubmissionResult( + val errors: List = emptyList(), + val fieldErrors: Map = emptyMap(), + val documentId: String? = null, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginConfigurationRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginConfigurationRepository.kt new file mode 100644 index 0000000000..c2cc3de697 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginConfigurationRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginConfigurationRepository : JpaRepository { + + fun findAllByDefinitionId(definitionId: UUID): List +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginDefinitionRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginDefinitionRepository.kt new file mode 100644 index 0000000000..411be6f369 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginDefinitionRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginDefinitionRepository : JpaRepository { + + fun findByPluginIdAndVersion(pluginId: String, version: String): ExternalPluginDefinition? + + fun findAllByPluginId(pluginId: String): List + + fun findAllByHostId(hostId: UUID): List +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedCapabilityRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedCapabilityRepository.kt new file mode 100644 index 0000000000..11e84fdf5b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedCapabilityRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginGrantedCapability +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginGrantedCapabilityRepository : JpaRepository { + + fun findAllByConfigurationId(configurationId: UUID): List + + fun deleteAllByConfigurationId(configurationId: UUID) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEndpointRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEndpointRepository.kt new file mode 100644 index 0000000000..bd680fcb9c --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEndpointRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginGrantedEndpointRepository : JpaRepository { + + fun findAllByConfigurationId(configurationId: UUID): List + + fun deleteAllByConfigurationId(configurationId: UUID) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEventRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEventRepository.kt new file mode 100644 index 0000000000..49c9d8afef --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginGrantedEventRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginGrantedEvent +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginGrantedEventRepository : JpaRepository { + + fun findAllByConfigurationId(configurationId: UUID): List + + fun deleteAllByConfigurationId(configurationId: UUID) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginHostRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginHostRepository.kt new file mode 100644 index 0000000000..90e045d5cf --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginHostRepository.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginHost +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginHostRepository : JpaRepository diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt new file mode 100644 index 0000000000..521b270c55 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.util.UUID + +interface ExternalPluginProcessLinkRepository : JpaRepository { + + fun findByProcessDefinitionIdAndActivityIdAndActivityType( + processDefinitionId: String, + activityId: String, + activityType: ActivityTypeWithEventName, + ): List + + fun findByProcessDefinitionId(processDefinitionId: String): List + + fun findAllByExternalPluginConfigurationIdIn( + externalPluginConfigurationIds: Collection, + ): List + + /** + * Links whose (design-time) reference pins one of the given plugin definition keys with the + * given reference type — used by the host delete guard to find `BUILDING_BLOCK` references, + * which carry no configuration id and are therefore invisible to the configuration-based + * usage queries above. + */ + @Query( + """ + select link from ExternalPluginProcessLink link + where link.pluginConfigurationReference.type = :referenceType + and link.pluginConfigurationReference.pluginDefinitionKey in :pluginDefinitionKeys + """ + ) + fun findAllByReferenceTypeAndPluginDefinitionKeyIn( + @Param("referenceType") referenceType: PluginConfigurationReferenceType, + @Param("pluginDefinitionKeys") pluginDefinitionKeys: Collection, + ): List +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt new file mode 100644 index 0000000000..83d6b6df00 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.repository + +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface ExternalPluginTaskFormProcessLinkRepository : JpaRepository { + + fun findByProcessDefinitionId(processDefinitionId: String): List + + fun findAllByExternalPluginConfigurationIdIn( + externalPluginConfigurationIds: Collection, + ): List +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/AbstractExternalPluginTokenFilter.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/AbstractExternalPluginTokenFilter.kt new file mode 100644 index 0000000000..556bda8bdd --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/AbstractExternalPluginTokenFilter.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter +import java.util.Collections +import java.util.Enumeration + +/** + * Template for the external-plugin token filters. Recognizes HS256 JWTs signed with this filter's + * key and carrying the expected `type` claim, and sets up Spring Security's `SecurityContext` with + * the principal produced by [authenticate]. + * + * Runs **before** Spring Security's `BearerTokenAuthenticationFilter`. When our token is detected + * and validated, the request is wrapped so the `Authorization` header is hidden from downstream + * filters — otherwise `BearerTokenAuthenticationFilter` would try to validate the same token + * against Keycloak's JWKS and reject it. + * + * Tokens that don't match our signing key / type are silently passed through; the OAuth2 + * resource-server filter chain handles them as before. + */ +abstract class AbstractExternalPluginTokenFilter( + keyProvider: ExternalPluginTokenKeyProvider, + private val expectedTokenType: String, +) : OncePerRequestFilter() { + + private val parser = Jwts.parser() + .verifyWith(keyProvider.signingKey) + .build() + + final override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response) + return + } + + val token = authHeader.removePrefix("Bearer ").trim() + val claims = try { + parser.parseSignedClaims(token).payload + } catch (_: JwtException) { + // Either not our token (signed with another key/algorithm) or invalid. Either way, + // let the downstream filter chain handle it. + filterChain.doFilter(request, response) + return + } + + val typeClaim = claims.get(ExternalPluginTokenKeyProvider.TYPE_CLAIM, String::class.java) + if (typeClaim != expectedTokenType) { + filterChain.doFilter(request, response) + return + } + + try { + val authentication = authenticate(token, claims) + SecurityContextHolder.getContext().authentication = authentication + kLogger.debug { "Authenticated external plugin token ($expectedTokenType) for ${authentication.name}" } + } catch (e: Exception) { + kLogger.warn(e) { "Failed to authenticate external plugin token ($expectedTokenType)" } + filterChain.doFilter(request, response) + return + } + + continueAuthenticated(AuthorizationStrippingRequestWrapper(request), response, filterChain) + } + + /** Turns a validated token into an [Authentication]; may throw to reject the token. */ + protected abstract fun authenticate(token: String, claims: Claims): Authentication + + /** + * Continues the filter chain after successful authentication. The request already hides the + * `Authorization` header. Subclasses decide whether PBAC stays active for the request. + */ + protected abstract fun continueAuthenticated( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) + + /** + * Hides the `Authorization` header from downstream filters so Spring Security's + * `BearerTokenAuthenticationFilter` does not try to re-authenticate the token. + */ + private class AuthorizationStrippingRequestWrapper(request: HttpServletRequest) : + HttpServletRequestWrapper(request) { + override fun getHeader(name: String): String? = + if (name.equals("Authorization", ignoreCase = true)) null else super.getHeader(name) + + override fun getHeaders(name: String): Enumeration = + if (name.equals("Authorization", ignoreCase = true)) { + Collections.emptyEnumeration() + } else super.getHeaders(name) + + override fun getHeaderNames(): Enumeration { + val names = super.getHeaderNames().toList().filter { !it.equals("Authorization", ignoreCase = true) } + return Collections.enumeration(names) + } + } + + companion object { + private val kLogger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginCallbackHttpSecurityConfigurer.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginCallbackHttpSecurityConfigurer.kt new file mode 100644 index 0000000000..80db18bc9f --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginCallbackHttpSecurityConfigurer.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.valtimo.contract.security.config.HttpConfigurerConfigurationException +import com.ritense.valtimo.contract.security.config.HttpSecurityConfigurer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter + +class ExternalPluginCallbackHttpSecurityConfigurer( + private val serviceTokenFilter: ExternalPluginServiceTokenFilter, + private val userTokenFilter: ExternalPluginUserTokenFilter, + private val allowlistFilter: ExternalPluginEndpointAllowlistFilter, +) : HttpSecurityConfigurer { + + override fun configure(http: HttpSecurity) { + try { + http.addFilterBefore(serviceTokenFilter, BearerTokenAuthenticationFilter::class.java) + http.addFilterBefore(userTokenFilter, BearerTokenAuthenticationFilter::class.java) + // Single allowlist filter, after authentication — it recognises both the service and the + // user principal and intersects each with the configuration's granted endpoints. + http.addFilterAfter(allowlistFilter, BearerTokenAuthenticationFilter::class.java) + } catch (e: Exception) { + throw HttpConfigurerConfigurationException(e) + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilter.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilter.kt new file mode 100644 index 0000000000..b60cc063da --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilter.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.web.filter.OncePerRequestFilter +import java.time.Duration +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Restricts external plugin tokens to the endpoints that were explicitly granted for the plugin + * configuration. Applies to **both** token kinds: + * + * - [ExternalPluginServicePrincipal] — the host's system credential. Reach is *only* the allowlist + * (PBAC is bypassed for the service token). + * - [ExternalPluginUserPrincipal] — the downscoped user token used by the iframe parent-proxy. Reach + * is **PBAC ∩ allowlist**: PBAC is enforced upstream (the recognising filter does not run without + * authorization) and this filter narrows it further to the granted set. + * + * Other authenticated principals (interactive Keycloak users, etc.) are unaffected. + * + * On top of the grants, a hard denylist ([DENYLIST_PATTERNS]) shields sensitive surfaces — external- + * plugin management (incl. host registration), user-token minting and role/permission management — + * from plugin tokens **regardless of what was granted**. One narrow exception: a **user** token may + * always `GET` the user-token introspection endpoint, which the plugin host needs to validate the + * token before executing Wasm (see the carve-out in [doFilterInternal]). + * + * Granted endpoints are compiled into request matchers and cached per configuration id for a short + * TTL so the per-request cost is a map lookup instead of a DB query. An invalid stored pattern is + * skipped with a warning (deny unless another grant matches) rather than failing the request. + */ +class ExternalPluginEndpointAllowlistFilter( + private val grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + private val cacheTtl: Duration = Duration.ofSeconds(30), +) : OncePerRequestFilter() { + + private val matcherCache = ConcurrentHashMap() + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val authentication = SecurityContextHolder.getContext().authentication + val principal = authentication?.principal + val configurationId = when (principal) { + is ExternalPluginServicePrincipal -> principal.pluginConfigId + is ExternalPluginUserPrincipal -> principal.pluginConfigId + else -> { + filterChain.doFilter(request, response) + return + } + } + + // Carve-out: a USER token may always introspect itself, regardless of denylist and grants. + // The plugin host must introspect user tokens against GZAC before executing Wasm for its + // public /data route, and the only credential it holds for that is the token itself. The + // endpoint is read-only (exact path, GET only) and leaks nothing beyond the token's own + // claims. Service-token principals get no carve-out — introspection is meaningless for them. + if (principal is ExternalPluginUserPrincipal && USER_TOKEN_INTROSPECT_MATCHER.matches(request)) { + filterChain.doFilter(request, response) + return + } + + // Hard denylist: sensitive surfaces are unreachable for plugin tokens regardless of grants. + if (DENYLIST_MATCHERS.any { it.matches(request) }) { + response.sendError( + HttpServletResponse.SC_FORBIDDEN, + "External plugins cannot access this endpoint", + ) + return + } + + val matched = grantedMatchers(configurationId).any { matcher -> + try { + matcher.matches(request) + } catch (e: Exception) { + kLogger.warn(e) { + "Granted endpoint pattern '${matcher.pattern}' for configuration $configurationId " + + "failed to match; treating as not matched" + } + false + } + } + if (!matched) { + response.sendError( + HttpServletResponse.SC_FORBIDDEN, + "Endpoint not allowed for external plugin service token", + ) + return + } + + filterChain.doFilter(request, response) + } + + private fun grantedMatchers(configurationId: UUID): List { + val cached = matcherCache[configurationId] + val now = System.currentTimeMillis() + if (cached != null && cached.expiresAtMillis > now) { + return cached.matchers + } + val matchers = grantedEndpointRepository.findAllByConfigurationId(configurationId) + .mapNotNull { granted -> + try { + AntPathRequestMatcher(granted.endpointPattern, granted.httpMethod) + } catch (e: Exception) { + // Patterns are validated at grant time, so this only fires for legacy/corrupt + // rows. Deny (skip) instead of failing the whole request with a 500. + kLogger.warn(e) { + "Invalid granted endpoint pattern '${granted.endpointPattern}' for " + + "configuration $configurationId; ignoring this grant" + } + null + } + } + matcherCache[configurationId] = CachedMatchers(now + cacheTtl.toMillis(), matchers) + return matchers + } + + private class CachedMatchers( + val expiresAtMillis: Long, + val matchers: List, + ) + + companion object { + /** + * Surfaces plugin tokens must never reach, regardless of grants: + * - external-plugin management (host registration, config/grant administration, uploads); + * - user-token minting (a plugin must not mint tokens for arbitrary users); + * - role and permission management (privilege escalation). + */ + val DENYLIST_PATTERNS: List = listOf( + "/api/management/v1/external-plugin/**", + "/api/v1/external-plugin/**", + "/api/management/v1/roles/**", + "/api/management/v1/permissions/**", + ) + + private val DENYLIST_MATCHERS = DENYLIST_PATTERNS.map { AntPathRequestMatcher(it) } + + /** Exact-path, GET-only carve-out for user-token introspection (see [doFilterInternal]). */ + private val USER_TOKEN_INTROSPECT_MATCHER = + AntPathRequestMatcher("/api/v1/external-plugin/user-token/introspect", "GET") + + private val kLogger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHmacSigner.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHmacSigner.kt new file mode 100644 index 0000000000..f4533252f9 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHmacSigner.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import java.util.HexFormat +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Signs outbound requests to the external plugin host with HMAC-SHA256 over a stable string + * representation of the request: `{method}\n{path}\n{timestamp}\n{bodyHash}`. + * + * The plugin host is expected to validate the same construction. Compromise scope is limited to + * an attacker who can also obtain the shared secret. + * + * Not a Spring bean: instances are constructed directly per request with the host's secret. + */ +class ExternalPluginHmacSigner( + private val secret: String, +) { + + fun sign(method: String, path: String, timestamp: String, bodyHash: String): String { + val mac = Mac.getInstance(ALGORITHM) + mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), ALGORITHM)) + val payload = "${method.uppercase()}\n$path\n$timestamp\n$bodyHash" + return HexFormat.of().formatHex(mac.doFinal(payload.toByteArray(Charsets.UTF_8))) + } + + fun bodyHash(body: ByteArray): String { + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(body) + return HexFormat.of().formatHex(digest) + } + + companion object { + private const val ALGORITHM = "HmacSHA256" + const val SIGNATURE_HEADER = "X-Valtimo-Signature" + const val TIMESTAMP_HEADER = "X-Valtimo-Timestamp" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHttpSecurityConfigurer.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHttpSecurityConfigurer.kt new file mode 100644 index 0000000000..7d97ecb87f --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginHttpSecurityConfigurer.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.valtimo.contract.authentication.AuthoritiesConstants.ADMIN +import com.ritense.valtimo.contract.security.config.HttpConfigurerConfigurationException +import com.ritense.valtimo.contract.security.config.HttpSecurityConfigurer +import org.springframework.http.HttpMethod.DELETE +import org.springframework.http.HttpMethod.GET +import org.springframework.http.HttpMethod.PATCH +import org.springframework.http.HttpMethod.POST +import org.springframework.http.HttpMethod.PUT +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher + +class ExternalPluginHttpSecurityConfigurer : HttpSecurityConfigurer { + + override fun configure(http: HttpSecurity) { + try { + http.authorizeHttpRequests { requests -> + requests + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/host")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/host")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(DELETE, "/api/management/v1/external-plugin/host/*")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(PATCH, "/api/management/v1/external-plugin/host/*/event-queue")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/host/*/usages")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/host-defaults")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/host/*/upload")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/definition")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/definition/*")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/definition/*/accept-content")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/configuration")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/configuration/*")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/configuration/*/usages")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/management/v1/external-plugin/configuration/*/logs")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/configuration")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(PUT, "/api/management/v1/external-plugin/configuration/*")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(DELETE, "/api/management/v1/external-plugin/configuration/*")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/configuration/*/revoke-tokens")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(POST, "/api/management/v1/external-plugin/endpoint-descriptions")).hasAuthority(ADMIN) + // Non-management: any authenticated user may mint a downscoped user token for a + // plugin tab — the result is always bounded by PBAC ∩ the plugin's allowlist. + .requestMatchers(antMatcher(POST, "/api/v1/external-plugin/configuration/*/user-token")).authenticated() + // Non-management: the plugin host introspects a user token before serving a /data + // call. The caller authenticates with the token itself; the resource rejects any + // principal that is not an external-plugin user principal. + .requestMatchers(antMatcher(GET, "/api/v1/external-plugin/user-token/introspect")).authenticated() + // Non-management: the menu-configuration builder lists activated page bundles. The + // list is unfiltered; access to page data is enforced at render time (PBAC ∩ allowlist). + .requestMatchers(antMatcher(GET, "/api/v1/external-plugin/menu-pages")).authenticated() + // Non-management: host origins for the frontend CSP (frame-src/connect-src). Every + // user rendering a plugin surface needs these; an origin exposes no secret. + .requestMatchers(antMatcher(GET, "/api/v1/external-plugin/host-origins")).authenticated() + // Non-management: submit a plugin task-form. GZAC completes the task server-side as + // the user — the standard COMPLETE permission is enforced in the submission service. + .requestMatchers( + antMatcher(POST, "/api/v1/process-link/*/external-plugin-task-form/submission") + ).authenticated() + } + } catch (e: Exception) { + throw HttpConfigurerConfigurationException(e) + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServicePrincipal.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServicePrincipal.kt new file mode 100644 index 0000000000..be8e30062d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServicePrincipal.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.valtimo.contract.authentication.SystemPrincipal +import java.util.UUID + +/** + * Spring Security principal representing an external plugin service-token caller. Carries no roles — + * endpoint access is enforced by [ExternalPluginEndpointAllowlistFilter]. Marked as a + * [SystemPrincipal] so user-scoped operations it triggers (e.g. creating a note) attribute to the + * system user rather than failing on a user lookup — the token has no Keycloak user. + */ +data class ExternalPluginServicePrincipal( + val pluginConfigId: UUID, + val pluginId: String, + val pluginVersion: String, +) : SystemPrincipal { + override fun toString(): String = "external-plugin:$pluginId:$pluginConfigId" +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenAuthenticator.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenAuthenticator.kt new file mode 100644 index 0000000000..2b89f45bbe --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenAuthenticator.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_CONFIG_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_VERSION_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.TOKEN_GENERATION_CLAIM +import com.ritense.valtimo.contract.security.jwt.TokenAuthenticator +import io.jsonwebtoken.Claims +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import java.util.UUID + +class ExternalPluginServiceTokenAuthenticator( + private val configurationRepository: ExternalPluginConfigurationRepository, +) : TokenAuthenticator { + + override fun supports(claims: Claims): Boolean = + claims[ExternalPluginServiceTokenKeyProvider.TYPE_CLAIM] == + ExternalPluginServiceTokenKeyProvider.TOKEN_TYPE + + override fun authenticate(jwt: String, claims: Claims): Authentication { + val configId = claims.get(PLUGIN_CONFIG_ID_CLAIM, String::class.java) + ?: error("$PLUGIN_CONFIG_ID_CLAIM claim missing on external plugin service token") + val pluginId = claims.get(PLUGIN_ID_CLAIM, String::class.java) + ?: error("$PLUGIN_ID_CLAIM claim missing on external plugin service token") + val pluginVersion = claims.get(PLUGIN_VERSION_CLAIM, String::class.java) + ?: error("$PLUGIN_VERSION_CLAIM claim missing on external plugin service token") + + requireCurrentTokenGeneration(configurationRepository, claims, UUID.fromString(configId)) + + val principal = ExternalPluginServicePrincipal( + pluginConfigId = UUID.fromString(configId), + pluginId = pluginId, + pluginVersion = pluginVersion, + ) + return UsernamePasswordAuthenticationToken(principal, jwt, emptyList()) + } + + companion object { + + /** + * Rejects a token whose generation is not the configuration's *current* one. This is the + * revocation mechanism: signature and expiry alone would keep a leaked token alive for its + * full TTL, whereas bumping the configuration's generation kills every outstanding token on + * the next use. A token for a configuration that no longer exists is rejected on the same + * grounds, and so is a token without the claim (pre-hardening tokens die at upgrade; the + * discovery cycle re-pushes fresh ones within a polling tick). + */ + fun requireCurrentTokenGeneration( + configurationRepository: ExternalPluginConfigurationRepository, + claims: Claims, + configId: UUID, + ) { + val tokenGeneration = (claims[TOKEN_GENERATION_CLAIM] as? Number)?.toLong() + ?: error("$TOKEN_GENERATION_CLAIM claim missing on external plugin token") + val configuration = configurationRepository.findById(configId).orElse(null) + ?: error("external plugin configuration $configId no longer exists") + check(tokenGeneration == configuration.tokenGeneration) { + "external plugin token for configuration $configId was revoked " + + "(token generation $tokenGeneration, current ${configuration.tokenGeneration})" + } + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilter.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilter.kt new file mode 100644 index 0000000000..a22fc99b92 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilter.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.authorization.AuthorizationContext +import io.jsonwebtoken.Claims +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.Authentication + +/** + * Recognizes external plugin service tokens (HS256 JWTs with `type=external_plugin_service`) and + * sets up Spring Security's `SecurityContext` with an `ExternalPluginServicePrincipal`. + * + * See [AbstractExternalPluginTokenFilter] for the shared recognition/pass-through mechanics. + */ +class ExternalPluginServiceTokenFilter( + keyProvider: ExternalPluginServiceTokenKeyProvider, + private val authenticator: ExternalPluginServiceTokenAuthenticator, +) : AbstractExternalPluginTokenFilter(keyProvider, ExternalPluginServiceTokenKeyProvider.TOKEN_TYPE) { + + override fun authenticate(token: String, claims: Claims): Authentication = + authenticator.authenticate(token, claims) + + override fun continueAuthenticated( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + // Bypass PBAC for the duration of this request: the service token is the authorization, + // and the URL surface is gated by ExternalPluginEndpointAllowlistFilter. + AuthorizationContext.runWithoutAuthorization { + filterChain.doFilter(request, response) + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenKeyProvider.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenKeyProvider.kt new file mode 100644 index 0000000000..1d054b2830 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenKeyProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +/** + * JWT signing key for external-plugin **service** tokens. Derived from + * `valtimo.plugin.encryption-secret` with the `service` domain suffix (see + * [ExternalPluginTokenKeyProvider]) so a service token can never validate as a user token or + * vice versa. + */ +class ExternalPluginServiceTokenKeyProvider(secret: String) : + ExternalPluginTokenKeyProvider(secret, DOMAIN) { + + override val tokenType: String = TOKEN_TYPE + + companion object { + const val TYPE_CLAIM = ExternalPluginTokenKeyProvider.TYPE_CLAIM + const val TOKEN_TYPE = "external_plugin_service" + private const val DOMAIN = "service" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginTokenKeyProvider.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginTokenKeyProvider.kt new file mode 100644 index 0000000000..f50b228ad8 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginTokenKeyProvider.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.valtimo.contract.security.jwt.provider.SecretKeyProvider +import io.jsonwebtoken.Claims +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import java.security.Key +import java.security.MessageDigest +import javax.crypto.SecretKey + +/** + * Base for the external-plugin JWT signing key providers. The key is derived from + * `valtimo.plugin.encryption-secret` via `SHA-256(secret + "|" + domain)`: + * + * - The hash makes the same configuration work regardless of the secret's raw length (AES-128 uses + * 16 bytes; HMAC-SHA256 needs 32) and prevents recovering the AES key from an exfiltrated token. + * - The domain suffix gives each token kind (`service`, `user`) its **own** key, so a token of one + * kind can never validate against the other kind's parser — the `type` claim is a routing hint, + * not the security boundary. + */ +abstract class ExternalPluginTokenKeyProvider( + secret: String, + domain: String, +) : SecretKeyProvider { + + init { + require(secret.isNotBlank()) { "valtimo.plugin.encryption-secret must not be blank" } + } + + val signingKey: SecretKey = Keys.hmacShaKeyFor( + MessageDigest.getInstance("SHA-256").digest("$secret|$domain".toByteArray(Charsets.UTF_8)) + ) + + /** The value of the `type` claim this provider's tokens carry. */ + protected abstract val tokenType: String + + @Suppress("DEPRECATION") + override fun supports(algorithm: SignatureAlgorithm, claims: Claims): Boolean = + algorithm == SignatureAlgorithm.HS256 && tokenType == claims[TYPE_CLAIM] + + @Suppress("DEPRECATION") + override fun getKey(algorithm: SignatureAlgorithm): Key? = + if (algorithm == SignatureAlgorithm.HS256) signingKey else null + + companion object { + const val TYPE_CLAIM = "type" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserPrincipal.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserPrincipal.kt new file mode 100644 index 0000000000..71577d1c7e --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserPrincipal.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import java.util.UUID + +/** + * Spring Security principal for an external-plugin **user** token. Resolves to the real logged-in + * user (so PBAC conditions referencing the current user behave exactly as they would for a Keycloak + * session) and additionally carries the [pluginConfigId] that [ExternalPluginEndpointAllowlistFilter] + * uses to intersect the user's reach with the plugin configuration's granted endpoints. + * + * Implements [UserDetails] so `Authentication.getName()` resolves to the user login via + * [getUsername] — which is what `SecurityUtils.getCurrentUserLogin()` reads. + * + * Deliberately **not** a `SystemPrincipal`: the whole point of the user token is that the work is + * attributed to, and authorized for, the actual user. + */ +data class ExternalPluginUserPrincipal( + val userLogin: String, + val roles: List, + val pluginConfigId: UUID, +) : UserDetails { + + override fun getAuthorities(): Collection = roles.map { SimpleGrantedAuthority(it) } + + override fun getPassword(): String? = null + + override fun getUsername(): String = userLogin + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + override fun toString(): String = "external-plugin-user:$userLogin:$pluginConfigId" +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenAuthenticator.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenAuthenticator.kt new file mode 100644 index 0000000000..32c356235d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenAuthenticator.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.service.ExternalPluginUserTokenService.Companion.PLUGIN_CONFIG_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginUserTokenService.Companion.ROLES_CLAIM +import com.ritense.valtimo.contract.security.jwt.TokenAuthenticator +import io.jsonwebtoken.Claims +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import java.util.UUID + +/** + * Rebuilds a *real user* [Authentication] from an external-plugin user token. The authorities are the + * roles frozen into the token, so `SecurityUtils.getCurrentUserRoles()` (and therefore PBAC) sees the + * user's actual roles — no Keycloak round-trip required for the (≤15 min) lifetime of the token. + * + * Like the service-token path, the token's generation claim must match the configuration's current + * [com.ritense.externalplugin.domain.ExternalPluginConfiguration.tokenGeneration] — revoking a + * configuration's tokens kills outstanding user tokens too, including their use against the host's + * `/data` route (the introspection endpoint authenticates with the token under introspection, so a + * revoked token no longer introspects successfully either). + */ +class ExternalPluginUserTokenAuthenticator( + private val configurationRepository: ExternalPluginConfigurationRepository, +) : TokenAuthenticator { + + override fun supports(claims: Claims): Boolean = + claims[ExternalPluginUserTokenKeyProvider.TYPE_CLAIM] == + ExternalPluginUserTokenKeyProvider.TOKEN_TYPE + + override fun authenticate(jwt: String, claims: Claims): Authentication { + val username = claims.subject + ?: error("subject claim missing on external plugin user token") + val configId = claims.get(PLUGIN_CONFIG_ID_CLAIM, String::class.java) + ?: error("$PLUGIN_CONFIG_ID_CLAIM claim missing on external plugin user token") + + ExternalPluginServiceTokenAuthenticator.requireCurrentTokenGeneration( + configurationRepository, + claims, + UUID.fromString(configId), + ) + + @Suppress("UNCHECKED_CAST") + val roles = (claims[ROLES_CLAIM] as? List) ?: emptyList() + + val principal = ExternalPluginUserPrincipal( + userLogin = username, + roles = roles, + pluginConfigId = UUID.fromString(configId), + ) + val authorities = roles.map { SimpleGrantedAuthority(it) } + return UsernamePasswordAuthenticationToken(principal, jwt, authorities) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilter.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilter.kt new file mode 100644 index 0000000000..c72342055d --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilter.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import io.jsonwebtoken.Claims +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.Authentication + +/** + * Recognizes external plugin **user** tokens (HS256 JWTs with `type=external_plugin_user`) and sets + * up Spring Security's `SecurityContext` with an [ExternalPluginUserPrincipal]. + * + * Mirrors [ExternalPluginServiceTokenFilter] with one critical divergence: it does **not** + * `runWithoutAuthorization`. The user token is *not* a system credential — it carries the user's real + * identity and roles so PBAC runs normally. Reach is intersected with the plugin's granted endpoints + * by [ExternalPluginEndpointAllowlistFilter] (which also recognises the user principal). + * + * See [AbstractExternalPluginTokenFilter] for the shared recognition/pass-through mechanics. + */ +class ExternalPluginUserTokenFilter( + keyProvider: ExternalPluginUserTokenKeyProvider, + private val authenticator: ExternalPluginUserTokenAuthenticator, +) : AbstractExternalPluginTokenFilter(keyProvider, ExternalPluginUserTokenKeyProvider.TOKEN_TYPE) { + + override fun authenticate(token: String, claims: Claims): Authentication = + authenticator.authenticate(token, claims) + + override fun continueAuthenticated( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + // NOTE: no runWithoutAuthorization here — PBAC must stay active for the user token. + filterChain.doFilter(request, response) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenKeyProvider.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenKeyProvider.kt new file mode 100644 index 0000000000..e389bb11a0 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenKeyProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +/** + * JWT signing key for external-plugin **user** tokens. Derived from + * `valtimo.plugin.encryption-secret` with the `user` domain suffix (see + * [ExternalPluginTokenKeyProvider]), giving user tokens a key of their own — distinct from the + * service-token key — so the two token kinds are cryptographically separated, not merely + * separated by the `type` claim. + * + * Unlike the service token, a user token is **not** a system credential: it carries the logged-in + * user's login and roles so GZAC runs normal PBAC against them. Endpoint reach is further intersected + * with the plugin configuration's granted-endpoint allowlist (see [ExternalPluginEndpointAllowlistFilter]). + */ +class ExternalPluginUserTokenKeyProvider(secret: String) : + ExternalPluginTokenKeyProvider(secret, DOMAIN) { + + override val tokenType: String = TOKEN_TYPE + + companion object { + const val TYPE_CLAIM = ExternalPluginTokenKeyProvider.TYPE_CLAIM + const val TOKEN_TYPE = "external_plugin_user" + private const val DOMAIN = "user" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/EndpointDescriptionService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/EndpointDescriptionService.kt new file mode 100644 index 0000000000..bcbbf17ce7 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/EndpointDescriptionService.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.endpoint.EndpointDescription as EndpointDescriptionAnnotation +import org.springframework.stereotype.Service +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping + +/** + * Resolves human-readable descriptions for API endpoint patterns by reading the [EndpointDescriptionAnnotation] + * declared on each controller handler method. Used by the management UI when an admin grants endpoint + * permissions to an external plugin. + */ +@Service +@SkipComponentScan +class EndpointDescriptionService( + private val handlerMappings: List, +) { + + private val descriptionsByKey: Map> by lazy { buildIndex() } + + /** + * Resolves descriptions for the given endpoint keys in the requested locale. + * Falls back to English, then to a null description if no annotation is registered. + */ + fun resolveDescriptions( + endpoints: List, + locale: String = "en", + ): List = endpoints.map { query -> + val method = query.method.uppercase() + val descriptions = findDescriptions(method, query.pattern) + EndpointDescription( + method = method, + pattern = query.pattern, + description = descriptions?.get(locale) ?: descriptions?.get("en"), + ) + } + + private fun buildIndex(): Map> { + val index = mutableMapOf>() + handlerMappings + .flatMap { it.handlerMethods.entries } + .forEach { (info, handlerMethod) -> + val annotation = handlerMethod.getMethodAnnotation(EndpointDescriptionAnnotation::class.java) + ?: return@forEach + val descriptions = mapOf("en" to annotation.en, "nl" to annotation.nl) + val methods = info.methodsCondition.methods + .map { it.name.uppercase() } + .ifEmpty { listOf("GET", "POST", "PUT", "PATCH", "DELETE") } + for (method in methods) { + for (pattern in info.patternValues) { + index["$method:$pattern"] = descriptions + } + } + } + return index + } + + private fun findDescriptions(method: String, pattern: String): Map? { + // Try exact match first + descriptionsByKey["$method:$pattern"]?.let { return it } + + // If the queried pattern contains wildcards, match against registered patterns. + // Plugin manifests use glob-style `*` while controllers use Spring `{param}` placeholders. + // Convert the query glob into a regex: `*` matches a single path segment (`[^/]+`), + // `**` matches any number of segments (`.+`). + if ("*" in pattern) { + val regex = buildGlobRegex(pattern) + return descriptionsByKey.entries + .firstOrNull { (key, _) -> key.startsWith("$method:") && regex.matches(key.substringAfter(":")) } + ?.value + } + + // If the queried pattern contains {param} placeholders, also try matching registered + // patterns that might use different placeholder names or `*`. + if ("{" in pattern) { + val regex = buildGlobRegex(pattern.replace(Regex("\\{[^}]+}"), "*")) + return descriptionsByKey.entries + .firstOrNull { (key, _) -> key.startsWith("$method:") && regex.matches(key.substringAfter(":")) } + ?.value + } + + return null + } + + private fun buildGlobRegex(glob: String): Regex { + val regexStr = buildString { + append("^") + var i = 0 + while (i < glob.length) { + when { + glob[i] == '*' && i + 1 < glob.length && glob[i + 1] == '*' -> { + append(".+") + i += 2 + } + glob[i] == '*' -> { + append("[^/]+") + i++ + } + glob[i] in "\\{}()[].\$^|+?" -> { + append("\\") + append(glob[i]) + i++ + } + else -> { + append(glob[i]) + i++ + } + } + } + append("$") + } + return Regex(regexStr) + } +} + +data class EndpointQuery( + val method: String, + val pattern: String, +) + +data class EndpointDescription( + val method: String, + val pattern: String, + val description: String?, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolver.kt new file mode 100644 index 0000000000..64f640e8a3 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolver.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Resolves an external-plugin configuration's frontend bundle of a given `type` to its absolute URL + * (`${definition.baseUrl}/${definition.version}${bundle.path}`, where `definition.baseUrl` is + * `{hostOrigin}/plugins/{pluginId}`). + * + * Generalises the original case-tab-only logic so every iframe-backed feature (case tabs, menu + * `page`s, user-task `task-form`s) shares one resolver. When [bundleKey] is null the sole bundle of + * that type is used (falling back to the first when several exist with no key); otherwise the bundle + * whose `key` matches is selected. Returns null when the configuration, definition, manifest or a + * matching bundle cannot be resolved. + */ +@Service +@SkipComponentScan +@Transactional(readOnly = true) +class ExternalPluginBundleUrlResolver( + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, +) { + + fun resolve(configurationId: UUID, bundleType: String, bundleKey: String?): String? { + val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return null + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) ?: return null + + val bundles = definition.manifestJson?.get("frontendBundles") ?: return null + if (!bundles.isArray) return null + + val typedBundles = bundles.filter { it.get("type")?.asText() == bundleType } + val bundle = when { + bundleKey != null -> typedBundles.firstOrNull { it.get("key")?.asText() == bundleKey } + else -> typedBundles.singleOrNull() ?: typedBundles.firstOrNull() + } ?: return null + + val path = bundle.get("path")?.asText() ?: return null + return "${definition.baseUrl}/${definition.version}$path" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt new file mode 100644 index 0000000000..6a33038654 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case_.service.ExternalPluginCaseTabResolver +import com.ritense.case_.service.ExternalPluginTabDefinition +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import java.util.UUID + +/** + * external-plugin's implementation of the case-module [ExternalPluginCaseTabResolver] SPI. Resolves + * a plugin configuration's `case-tab` bundle to its absolute URL by delegating to the shared + * [ExternalPluginBundleUrlResolver] with the `case-tab` bundle type (behaviour-preserving), and the + * configuration's plugin definition (`pluginId`/version) for the self-describing tab export. + */ +@Service +@SkipComponentScan +class ExternalPluginCaseTabResolverImpl( + private val bundleUrlResolver: ExternalPluginBundleUrlResolver, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, +) : ExternalPluginCaseTabResolver { + + override fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? = + bundleUrlResolver.resolve(configurationId, CASE_TAB_TYPE, bundleKey) + + override fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? { + val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return null + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) ?: return null + return ExternalPluginTabDefinition(definition.pluginId, definition.version) + } + + companion object { + private const val CASE_TAB_TYPE = "case-tab" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt new file mode 100644 index 0000000000..f6d2d01269 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.case_.service.ExternalPluginTabDefinition +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import java.util.UUID + +/** + * external-plugin's implementation of the case-module [ExternalPluginCaseWidgetResolver] SPI. The + * widget sibling of [ExternalPluginCaseTabResolverImpl]: resolves a plugin configuration's + * `case-widget` bundle to its absolute URL by delegating to the shared + * [ExternalPluginBundleUrlResolver] with the `case-widget` bundle type, and the configuration's + * plugin definition (`pluginId`/version) for the self-describing widget export. + */ +@Service +@SkipComponentScan +class ExternalPluginCaseWidgetResolverImpl( + private val bundleUrlResolver: ExternalPluginBundleUrlResolver, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, +) : ExternalPluginCaseWidgetResolver { + + override fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? = + bundleUrlResolver.resolve(configurationId, CASE_WIDGET_TYPE, bundleKey) + + override fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? { + val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return null + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) ?: return null + return ExternalPluginTabDefinition(definition.pluginId, definition.version) + } + + companion object { + private const val CASE_WIDGET_TYPE = "case-widget" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt new file mode 100644 index 0000000000..9878d907a8 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case.repository.CaseTabSpecificationHelper +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.service.CaseExternalPluginWidgetService +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormProcessLinkMapper +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto.Companion.SOURCE_EXTERNAL +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * External-plugin counterpart to `PluginConfigurationMappingResolverImpl` (D1). Repairs `FIXED` + * external-plugin process links (service-task and task-form), and `EXTERNAL_PLUGIN` case tabs whose + * `contentKey`-embedded configuration id is dangling — behind the same + * `dangling-plugin-configurations` / `plugin-configuration-mappings` endpoints, distinguished by the + * `source` discriminator on [DanglingPluginConfigurationDto]. + */ +@Transactional +open class ExternalPluginConfigurationMappingResolver( + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + private val caseTabRepository: CaseTabRepository, + private val caseExternalPluginWidgetService: CaseExternalPluginWidgetService, + private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + private val caseDefinitionChecker: CaseDefinitionChecker, + private val applicationEventPublisher: ApplicationEventPublisher, +) : PluginConfigurationMappingResolver { + + override fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) { + caseDefinitionChecker.assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, ALL_ISSUE_TYPES) + + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + + resolveProcessLinks(processDefinitionIds, mappings) + resolveTaskFormProcessLinks(processDefinitionIds, mappings) + resolveCaseTabs(caseDefinitionId, mappings) + caseExternalPluginWidgetService.remapConfiguration(caseDefinitionId, mappings) + + checkForRemainingIssues(caseDefinitionId, processDefinitionIds) + } + + override fun getDanglingPluginConfigurations(caseDefinitionId: CaseDefinitionId): List { + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + + val danglingLinks = allLinks(processDefinitionIds) + .filter { it.pluginConfigurationReference.type == FIXED } + .filter { link -> + val configId = link.externalPluginConfigurationId + configId == null || !configurationRepository.existsById(configId) + } + .groupBy { it.pluginConfigurationReference.pluginDefinitionKey to it.pluginConfigurationReference.pluginDefinitionVersion } + .map { (keyAndVersion, links) -> + val (definitionKey, version) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = definitionKey, + sourcePluginConfigurationIds = links.map { it.externalPluginConfigurationId ?: it.id }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = version, + ) + } + + val danglingTaskFormLinks = allTaskFormLinks(processDefinitionIds) + .filter { !configurationRepository.existsById(it.externalPluginConfigurationId) } + .groupBy { it.pluginConfigurationReference.pluginDefinitionKey to it.pluginConfigurationReference.pluginDefinitionVersion } + .map { (keyAndVersion, links) -> + val (definitionKey, version) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = definitionKey, + sourcePluginConfigurationIds = links.map { it.externalPluginConfigurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = version, + ) + } + + val danglingTabConfigurations = danglingTabsFor(caseDefinitionId) + val danglingWidgetConfigurations = danglingWidgetsFor(caseDefinitionId) + + return danglingLinks + danglingTaskFormLinks + danglingTabConfigurations + danglingWidgetConfigurations + } + + override fun recheckIssuesForProcessDefinition(processDefinitionId: String) { + val link = processDefinitionCaseDefinitionService + .findByProcessDefinitionIdOrNull(ProcessDefinitionId.of(processDefinitionId)) + ?: return + val caseDefinitionId = link.id.caseDefinitionId + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + checkForRemainingIssues(caseDefinitionId, processDefinitionIds) + } + + /** + * In-transaction recheck for a whole case definition, triggered from `CaseTabImporter.afterImport` + * and `CaseWidgetTabImporter.afterImport` so a dangling `EXTERNAL_PLUGIN` case tab or + * `external-plugin` case widget is detected reliably at import time. (Neither surface has a + * process link, so they would otherwise depend on an incidental process-link recheck, which fires + * only AFTER_COMMIT and does not persist during import.) Also triggered from + * `CaseWidgetService.updateWidgetTab` so a widget saved over management REST with an unresolvable + * configuration id raises its issue immediately. Re-publishes all four per-surface verdicts; + * idempotent when a surface is already correct. + */ + override fun recheckIssuesForCaseDefinition(caseDefinitionId: CaseDefinitionId) { + checkForRemainingIssues(caseDefinitionId, processDefinitionIdsFor(caseDefinitionId)) + } + + private fun resolveProcessLinks(processDefinitionIds: List, mappings: Map) { + val links = allLinks(processDefinitionIds).filter { it.pluginConfigurationReference.type == FIXED } + for (link in links) { + val lookupId = link.externalPluginConfigurationId ?: link.id + val mappedId = mappings[lookupId] ?: continue + + val updated = link.copy( + externalPluginConfigurationId = mappedId, + pluginConfigurationReference = PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = link.pluginConfigurationReference.pluginDefinitionKey, + pluginDefinitionVersion = link.pluginConfigurationReference.pluginDefinitionVersion, + ), + ) + processLinkRepository.save(updated) + } + } + + private fun resolveTaskFormProcessLinks(processDefinitionIds: List, mappings: Map) { + val links = allTaskFormLinks(processDefinitionIds) + for (link in links) { + val mappedId = mappings[link.externalPluginConfigurationId] ?: continue + + val updated = link.copy( + externalPluginConfigurationId = mappedId, + pluginConfigurationReference = PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = link.pluginConfigurationReference.pluginDefinitionKey, + pluginDefinitionVersion = link.pluginConfigurationReference.pluginDefinitionVersion, + ), + ) + taskFormProcessLinkRepository.save(updated) + } + } + + private fun resolveCaseTabs(caseDefinitionId: CaseDefinitionId, mappings: Map) { + val externalPluginTabs = caseTabsFor(caseDefinitionId) + .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } + + for (tab in externalPluginTabs) { + val configPart = tab.contentKey.substringBefore(':') + val bundlePart = tab.contentKey.substringAfter(':', "") + val originalId = configPart.toUuidOrNull() ?: continue + val mappedId = mappings[originalId] ?: continue + + val newContentKey = if (bundlePart.isEmpty()) mappedId.toString() else "$mappedId:$bundlePart" + val updatedTab = tab.copy(contentKey = newContentKey) + val existingSideRow = caseExternalPluginTabRepository.findByIdOrNull(tab.id) + caseTabRepository.save(updatedTab) + caseExternalPluginTabRepository.save( + CaseExternalPluginTab( + id = updatedTab.id, + externalPluginConfigurationId = mappedId, + bundleKey = bundlePart.ifEmpty { null }, + // The chooser maps to a configuration of the same plugin, so the plugin identity + // is unchanged — preserve it rather than re-deriving it. + pluginDefinitionKey = existingSideRow?.pluginDefinitionKey, + pluginDefinitionVersion = existingSideRow?.pluginDefinitionVersion, + ) + ) + } + } + + private fun danglingTabsFor(caseDefinitionId: CaseDefinitionId): List { + val danglingTabs = caseTabsFor(caseDefinitionId) + .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } + .mapNotNull { caseExternalPluginTabRepository.findByIdOrNull(it.id) } + .filter { !configurationRepository.existsById(it.externalPluginConfigurationId) } + + if (danglingTabs.isEmpty()) return emptyList() + + // Group by the persisted plugin identity so the repair panel can offer a per-plugin chooser, + // exactly like a dangling process link — instead of the old single key-less "unidentifiable" + // entry that forced a manual reconfigure. + return danglingTabs + .groupBy { it.pluginDefinitionKey to it.pluginDefinitionVersion } + .map { (keyAndVersion, sideRows) -> + val (pluginDefinitionKey, pluginDefinitionVersion) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = pluginDefinitionKey, + sourcePluginConfigurationIds = sideRows.map { it.externalPluginConfigurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + } + } + + /** + * Dangling = external-plugin widgets whose referenced configuration cannot be resolved in this + * environment, grouped by the design-time plugin identity carried from a self-describing import + * (so the repair panel can offer a per-plugin chooser, exactly like a dangling tab). The source + * ids are the widgets' current (unresolvable) configuration ids — the same ids the repair maps + * from. + */ + private fun danglingWidgetsFor(caseDefinitionId: CaseDefinitionId): List { + val danglingWidgets = caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId) + .filter { it.configurationId != null && !configurationRepository.existsById(it.configurationId) } + + if (danglingWidgets.isEmpty()) return emptyList() + + return danglingWidgets + .groupBy { it.pluginDefinitionKey to it.pluginDefinitionVersion } + .map { (keyAndVersion, widgets) -> + val (pluginDefinitionKey, pluginDefinitionVersion) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = pluginDefinitionKey, + sourcePluginConfigurationIds = widgets.mapNotNull { it.configurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + } + } + + /** + * Each external surface owns its own issue type and is judged independently, so one surface being + * clean can never clear another surface's issue (the cross-surface clobber that a single shared + * type suffered). Mirrors the per-surface `afterImport` detection on the mappers. + */ + private fun checkForRemainingIssues(caseDefinitionId: CaseDefinitionId, processDefinitionIds: List) { + publishIssue(caseDefinitionId, PROCESS_LINK_ISSUE_TYPE, hasProcessLinkIssue(processDefinitionIds)) + publishIssue(caseDefinitionId, TASK_FORM_ISSUE_TYPE, hasTaskFormIssue(processDefinitionIds)) + publishIssue(caseDefinitionId, CASE_TAB_ISSUE_TYPE, danglingTabsFor(caseDefinitionId).isNotEmpty()) + publishIssue(caseDefinitionId, CASE_WIDGET_ISSUE_TYPE, danglingWidgetsFor(caseDefinitionId).isNotEmpty()) + } + + private fun hasProcessLinkIssue(processDefinitionIds: List) = + allLinks(processDefinitionIds).any { link -> + link.pluginConfigurationReference.type == FIXED && + (link.externalPluginConfigurationId == null || !configurationRepository.existsById(link.externalPluginConfigurationId)) + } + + private fun hasTaskFormIssue(processDefinitionIds: List) = + allTaskFormLinks(processDefinitionIds).any { link -> + !configurationRepository.existsById(link.externalPluginConfigurationId) + } + + private fun publishIssue(caseDefinitionId: CaseDefinitionId, issueType: String, hasIssue: Boolean) { + val event = if (hasIssue) { + CaseConfigurationIssueDetectedEvent(caseDefinitionId, issueType) + } else { + CaseConfigurationIssueResolvedEvent(caseDefinitionId, issueType) + } + applicationEventPublisher.publishEvent(event) + } + + private fun processDefinitionIdsFor(caseDefinitionId: CaseDefinitionId): List = + processDefinitionCaseDefinitionService + .findProcessDefinitionCaseDefinitions(caseDefinitionId) + .map { it.id.processDefinitionId.id } + + private fun allLinks(processDefinitionIds: List): List = + processDefinitionIds.flatMap { processLinkRepository.findByProcessDefinitionId(it) } + + private fun allTaskFormLinks(processDefinitionIds: List): List = + processDefinitionIds.flatMap { taskFormProcessLinkRepository.findByProcessDefinitionId(it) } + + private fun caseTabsFor(caseDefinitionId: CaseDefinitionId): List = + caseTabRepository.findAll(CaseTabSpecificationHelper.byCaseDefinitionId(caseDefinitionId)) + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } + + companion object { + val PROCESS_LINK_ISSUE_TYPE = ExternalPluginProcessLinkMapper.ISSUE_TYPE + val TASK_FORM_ISSUE_TYPE = ExternalPluginTaskFormProcessLinkMapper.ISSUE_TYPE + const val CASE_TAB_ISSUE_TYPE = "external-plugin-case-tab" + const val CASE_WIDGET_ISSUE_TYPE = "external-plugin-case-widget" + + private val ALL_ISSUE_TYPES = listOf( + PROCESS_LINK_ISSUE_TYPE, + TASK_FORM_ISSUE_TYPE, + CASE_TAB_ISSUE_TYPE, + CASE_WIDGET_ISSUE_TYPE, + ) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationService.kt new file mode 100644 index 0000000000..4092e98d1c --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationService.kt @@ -0,0 +1,624 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginCapability +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginGrantedCapability +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import com.ritense.externalplugin.domain.ExternalPluginGrantedEvent +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.exception.ExternalPluginConfigurationInUseException +import com.ritense.externalplugin.exception.ExternalPluginNotFoundException +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedCapabilityRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.externalplugin.web.rest.dto.GrantedEndpointEntry +import com.ritense.externalplugin.web.rest.dto.GrantedEventEntry +import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Instant +import java.util.UUID + +/** + * Transaction boundaries are deliberately per-method (no class-level `@Transactional`): all host + * HTTP I/O (config pushes/deletes) happens **after** the local transaction commits — see + * [runAfterCommit] — so a slow or unreachable host can never pin a database transaction open. + */ +@Service +@SkipComponentScan +class ExternalPluginConfigurationService( + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val hostRepository: ExternalPluginHostRepository, + private val grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + private val grantedEventRepository: ExternalPluginGrantedEventRepository, + private val grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository, + private val hostClient: ExternalPluginHostClient, + private val propertyEncryptor: PluginPropertyEncryptor, + private val encryptionService: EncryptionService, + private val objectMapper: ObjectMapper, + private val serviceTokenService: ExternalPluginServiceTokenService, + private val hostUsageResolver: ExternalPluginHostUsageResolver, + /** + * Default exchange GZAC publishes to (from `valtimo.outbox.publisher.rabbitmq.exchange`). + * Used as a fallback when a host row has `eventBrokerExchange = null`. + */ + private val defaultEventBrokerExchange: String, + /** + * Fallback callback URL — local-dev default `http://localhost:{server.port}`. Only used when + * a host row was created before `gzacCallbackBaseUrl` became required (legacy data); new hosts + * always carry a non-null value entered in the add-host UI. + */ + private val fallbackGzacBaseUrl: String, +) { + + @Transactional(readOnly = true) + fun list(definitionId: UUID? = null): List = if (definitionId != null) { + configurationRepository.findAllByDefinitionId(definitionId) + } else { + configurationRepository.findAll() + } + + @Transactional(readOnly = true) + fun get(id: UUID): ExternalPluginConfiguration = configurationRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin configuration", id) } + + @Transactional + fun create( + definitionId: UUID, + title: String, + properties: ObjectNode, + grantedEndpoints: List, + grantedEvents: List, + grantedCapabilities: List = emptyList(), + ): ExternalPluginConfiguration { + val definition = definitionRepository.findById(definitionId) + .orElseThrow { IllegalArgumentException("External plugin definition $definitionId not found") } + + // Rejects unknown capability names before anything is persisted. + val capabilities = grantedCapabilities.map(ExternalPluginCapability::fromValue) + + validateAgainstSchema(properties, definition.configSchema) + validateGrantedEndpointsCoverManifest(grantedEndpoints, definition) + validateGrantedEventsCoverManifest(grantedEvents, definition) + validateGrantedCapabilitiesCoverManifest(capabilities, definition) + + val encrypted = propertyEncryptor.encryptSecretFields(properties.deepCopy(), definition.configSchema) + + val configuration = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definitionId, + title = title, + properties = encrypted, + createdAt = Instant.now(), + ) + val saved = configurationRepository.save(configuration) + + saveGrantedEndpoints(saved.id, grantedEndpoints) + saveGrantedEvents(saved.id, grantedEvents) + saveGrantedCapabilities(saved.id, capabilities) + + // Push the decrypted config to the plugin host once the transaction has committed, so the + // HTTP call never runs inside the database transaction. + pushToHostAfterCommit(saved, definition) + + return saved + } + + /** + * Registers an after-commit push of [configuration] to its host. Failures are surfaced as + * warnings only: the discovery service re-pushes every configuration on its next cycle, so a + * failed push self-heals. + */ + private fun pushToHostAfterCommit(configuration: ExternalPluginConfiguration, definition: ExternalPluginDefinition) { + runAfterCommit { + try { + val host = hostRepository.findById(definition.hostId).orElse(null) + if (host != null) { + val pushed = pushToHost(configuration, definition, host) + if (!pushed) { + logger.warn { + "Failed to push configuration ${configuration.id} to plugin host ${host.id} " + + "(will be synced on next discovery)" + } + } + } + } catch (e: Exception) { + logger.warn(e) { + "Failed to push configuration ${configuration.id} to plugin host (will be synced on next discovery)" + } + } + } + } + + /** + * Runs [action] after the surrounding transaction commits, or immediately when no transaction + * is active (e.g. direct calls from the discovery service, which manages its own boundaries). + */ + private fun runAfterCommit(action: () -> Unit) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun afterCommit() = action() + }) + } else { + action() + } + } + + /** + * Pushes a configuration to its plugin host along with a freshly-issued service token and the + * GZAC base URL the host should call back on. Used both for new configurations and for the + * discovery service's periodic re-sync. + * + * Deliberately **not** transactional: this performs HTTP I/O and must never run inside a + * database transaction. Callers invoke it after their own transaction has committed. + */ + fun pushToHost( + configuration: ExternalPluginConfiguration, + definition: ExternalPluginDefinition, + host: ExternalPluginHost, + ): Boolean { + if (definition.requiresReacceptance) { + // Central guard — every push path (create, update, discovery re-sync, token revocation) + // funnels through here. No push means no fresh service token for plugin code that + // differs from what the admin accepted. + logger.warn { + "Refusing to push configuration ${configuration.id}: plugin " + + "'${definition.pluginId}@${definition.version}' changed on its host and awaits re-acceptance" + } + return false + } + val adminToken = encryptionService.decrypt(host.secret) + val decrypted = decryptedProperties(configuration) + val serviceToken = serviceTokenService.issue(configuration, definition) + // The granted set is the authoritative subscription list — the host dispatches strictly + // based on this, not on the manifest's declared `eventSubscriptions`. A later manifest + // update that adds an event type cannot silently start delivering it without admin re-grant. + val grantedEventTypes = grantedEventRepository.findAllByConfigurationId(configuration.id) + .map { it.eventType } + val grantedCaps = grantedCapabilityRepository.findAllByConfigurationId(configuration.id) + .map { it.capability.value } + // The granted endpoint list travels with the push so the host enforces it on every + // `gzac_api` call, independent of GZAC-side token scoping. + val grantedEndpointPairs = grantedEndpointRepository.findAllByConfigurationId(configuration.id) + .map { it.httpMethod to it.endpointPattern } + val pushed = hostClient.pushConfiguration( + baseUrl = host.baseUrl, + adminToken = adminToken, + configId = configuration.id.toString(), + pluginId = definition.pluginId, + pluginVersion = definition.version, + properties = decrypted, + serviceToken = serviceToken, + gzacBaseUrl = host.gzacCallbackBaseUrl ?: fallbackGzacBaseUrl, + // The pinned package hash rides along; the host refuses the push (409) if the package + // on disk no longer matches, closing the window between discovery and this push. + expectedContentHash = definition.contentHash, + eventSubscriptions = grantedEventTypes, + grantedCapabilities = grantedCaps, + grantedEndpoints = grantedEndpointPairs, + eventBrokerUrl = host.eventBrokerAmqpUrl, + eventBrokerExchange = host.eventBrokerExchange ?: defaultEventBrokerExchange, + eventBrokerExchangeType = "fanout", + eventQueueMode = host.eventQueueMode, + eventQueueTtlMs = host.eventQueueTtlMs, + ) + if (pushed) { + logger.info { "Pushed configuration ${configuration.id} for plugin '${definition.pluginId}' to host ${host.id}" } + } + return pushed + } + + @Transactional + fun update( + id: UUID, + title: String, + properties: ObjectNode, + grantedEndpoints: List? = null, + ): ExternalPluginConfiguration { + val config = configurationRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin configuration", id) } + val definition = definitionRepository.findById(config.definitionId) + .orElseThrow { ExternalPluginNotFoundException("External plugin definition", config.definitionId) } + + // GET responses omit `x-secret` properties (see maskedProperties), so an absent or blank + // secret in the update payload means "unchanged": the stored ciphertext is kept as-is and + // the stored plaintext is substituted for schema validation. + val secretFields = propertyEncryptor.secretFieldNames(definition.configSchema) + val unchangedSecretFields = secretFields.filter { field -> + val incoming = properties.get(field) + val omitted = incoming == null || incoming.isNull || (incoming.isTextual && incoming.asText().isEmpty()) + omitted && config.properties?.get(field)?.isTextual == true + } + + val validationCopy = properties.deepCopy() + unchangedSecretFields.forEach { field -> + val storedCiphertext = config.properties!!.get(field).asText() + if (storedCiphertext.isNotEmpty()) { + validationCopy.put(field, encryptionService.decrypt(storedCiphertext)) + } + } + validateAgainstSchema(validationCopy, definition.configSchema) + + if (grantedEndpoints != null) { + validateGrantedEndpointsCoverManifest(grantedEndpoints, definition) + grantedEndpointRepository.deleteAllByConfigurationId(id) + // Flush the delete before re-inserting: Hibernate orders inserts ahead of deletes + // within a flush, which would trip the (configuration_id, http_method, endpoint_pattern) + // unique constraint when the replacement set overlaps the previous grants. + grantedEndpointRepository.flush() + saveGrantedEndpoints(id, grantedEndpoints) + } + + val encrypted = propertyEncryptor.encryptSecretFields(properties.deepCopy(), definition.configSchema) + // Keep the existing ciphertext for untouched secrets instead of encrypting the empty/absent + // placeholder the browser sent back. + unchangedSecretFields.forEach { field -> + encrypted.set(field, config.properties!!.get(field)) + } + + config.title = title + config.properties = encrypted + val saved = configurationRepository.save(config) + + // Push the updated decrypted config to the plugin host after commit (never inside the tx). + pushToHostAfterCommit(saved, definition) + + return saved + } + + /** + * Revokes every outstanding token (service *and* user) minted for this configuration by + * bumping its generation counter: tokens carry the generation they were minted under and only + * validate while it matches. After the bump commits, a fresh push hands the host a new token of + * the new generation, so a *legitimate* host recovers instantly while every leaked or hoarded + * token is dead. If the push cannot happen (host down, or the definition awaits content + * re-acceptance) the discovery cycle re-pushes on its next tick — or withholds, which is then + * exactly the intent. + */ + @Transactional + fun revokeTokens(id: UUID): ExternalPluginConfiguration { + val config = configurationRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin configuration", id) } + val definition = definitionRepository.findById(config.definitionId) + .orElseThrow { ExternalPluginNotFoundException("External plugin definition", config.definitionId) } + + config.tokenGeneration += 1 + val saved = configurationRepository.save(config) + logger.info { + "Revoked all tokens for external plugin configuration $id " + + "(new token generation ${saved.tokenGeneration})" + } + + pushToHostAfterCommit(saved, definition) + return saved + } + + /** + * Applies an admin-confirmed overwrite of an existing plugin version (§11): the admin + * re-reviewed the uploaded package's requested permissions in the upload flow, so the new + * package hash is pinned as the accepted content and every configuration of the definition is + * re-granted to **exactly** the new manifest's declared endpoint/event/capability sets — the + * same all-or-nothing footprint the activation screen grants. The subsequent discovery cycle + * refreshes the stored manifest (the hash now matches the pin) and pushes the new grants. + * + * A definition GZAC never discovered is a no-op: discovery will pin the uploaded content on + * first sight and there are no configurations to re-grant. + */ + @Transactional + fun applyApprovedOverwrite(pluginId: String, version: String, contentHash: String?, manifest: JsonNode?) { + val definition = definitionRepository.findByPluginIdAndVersion(pluginId, version) ?: return + + if (contentHash != null) { + definition.contentHash = contentHash + definition.pendingContentHash = null + definitionRepository.save(definition) + } + if (manifest == null) return + + val declaredEndpoints = declaredEndpoints(manifest) + val declaredEvents = declaredEvents(manifest) + val declaredCapabilities = declaredCapabilities(manifest) + + configurationRepository.findAllByDefinitionId(definition.id).forEach { configuration -> + grantedEndpointRepository.deleteAllByConfigurationId(configuration.id) + grantedEventRepository.deleteAllByConfigurationId(configuration.id) + grantedCapabilityRepository.deleteAllByConfigurationId(configuration.id) + // Flush the deletes before re-inserting — same unique-constraint ordering concern as + // in [update]. + grantedEndpointRepository.flush() + grantedEventRepository.flush() + grantedCapabilityRepository.flush() + saveGrantedEndpoints(configuration.id, declaredEndpoints) + saveGrantedEvents(configuration.id, declaredEvents) + saveGrantedCapabilities(configuration.id, declaredCapabilities) + logger.info { + "Re-granted configuration ${configuration.id} to the overwritten manifest of " + + "'$pluginId@$version' (${declaredEndpoints.size} endpoints, " + + "${declaredEvents.size} events, ${declaredCapabilities.size} capabilities)" + } + } + } + + private fun declaredEndpoints(manifest: JsonNode): List { + val declared = manifest.get("permissions")?.get("endpoints") ?: return emptyList() + if (!declared.isArray) return emptyList() + return declared.mapNotNull { endpoint -> + val method = endpoint.get("method")?.asText()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val pattern = endpoint.get("pattern")?.asText()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + GrantedEndpointEntry(method, pattern) + } + } + + private fun declaredEvents(manifest: JsonNode): List { + val declared = manifest.get("eventSubscriptions") ?: return emptyList() + if (!declared.isArray) return emptyList() + return declared.mapNotNull { it.asText().takeIf { s -> s.isNotBlank() } }.map(::GrantedEventEntry) + } + + private fun declaredCapabilities(manifest: JsonNode): List { + val declared = manifest.get("permissions")?.get("capabilities") ?: return emptyList() + if (!declared.isArray) return emptyList() + return declared.mapNotNull { capability -> + val value = capability.asText().takeIf { it.isNotBlank() } ?: return@mapNotNull null + try { + ExternalPluginCapability.fromValue(value) + } catch (e: IllegalArgumentException) { + // Unknown to this GZAC version — grant what is known rather than failing after the + // host has already replaced the package; the host-side guard denies the rest anyway. + logger.warn { "Skipping unknown capability '$value' while re-granting after overwrite: ${e.message}" } + null + } + } + } + + /** + * Mirrors [ExternalPluginHostService.findUsages] but scoped to a single configuration. Used + * by the management UI to disable the delete control proactively; the server-side guard in + * [delete] still enforces the same invariant, so an empty list here does not authorise + * deletion — a concurrent process-link creation will still surface as an + * [ExternalPluginConfigurationInUseException]. + */ + @Transactional(readOnly = true) + fun findUsages(configurationId: UUID): List = + hostUsageResolver.findUsagesForConfiguration(configurationId) + + @Transactional + fun delete(id: UUID) { + val config = configurationRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin configuration", id) } + + val usages = hostUsageResolver.findUsagesForConfiguration(id) + if (usages.isNotEmpty()) { + throw ExternalPluginConfigurationInUseException(id, usages) + } + + val definition = definitionRepository.findById(config.definitionId).orElse(null) + val host = definition?.let { hostRepository.findById(it.hostId).orElse(null) } + + grantedEndpointRepository.deleteAllByConfigurationId(id) + grantedEventRepository.deleteAllByConfigurationId(id) + grantedCapabilityRepository.deleteAllByConfigurationId(id) + configurationRepository.delete(config) + + // Remove the config from the plugin host after the local delete has committed, so the HTTP + // call never runs inside the database transaction. + if (host != null) { + runAfterCommit { + try { + val adminToken = encryptionService.decrypt(host.secret) + val deleted = hostClient.deleteConfiguration(host.baseUrl, adminToken, id.toString()) + if (deleted) { + logger.info { "Deleted configuration $id from host ${host.id}" } + } else { + logger.warn { "Failed to delete configuration $id from plugin host ${host.id}" } + } + } catch (e: Exception) { + logger.warn(e) { "Failed to delete configuration $id from plugin host" } + } + } + } + } + + @Transactional(readOnly = true) + fun getGrantedEndpoints(configurationId: UUID): List = + grantedEndpointRepository.findAllByConfigurationId(configurationId) + + @Transactional(readOnly = true) + fun getGrantedEvents(configurationId: UUID): List = + grantedEventRepository.findAllByConfigurationId(configurationId) + + @Transactional(readOnly = true) + fun getGrantedCapabilities(configurationId: UUID): List = + grantedCapabilityRepository.findAllByConfigurationId(configurationId) + + /** + * Decrypted properties for **server-side use only** (the host push). Never expose the result + * over REST — [maskedProperties] is the read-model for API responses. + */ + @Transactional(readOnly = true) + fun decryptedProperties(configuration: ExternalPluginConfiguration): ObjectNode { + val definition = definitionRepository.findById(configuration.definitionId) + .orElseThrow { ExternalPluginNotFoundException("External plugin definition", configuration.definitionId) } + val source = configuration.properties ?: objectMapper.createObjectNode() + return propertyEncryptor.decryptSecretFields(source.deepCopy(), definition.configSchema) + } + + /** + * Properties safe to return to the browser: `x-secret` fields are omitted entirely (mirroring + * the embedded plugin module's `PluginConfigurationDto`). On update, an absent/blank secret + * field is treated as "unchanged" — see [update]. + */ + @Transactional(readOnly = true) + fun maskedProperties(configuration: ExternalPluginConfiguration): ObjectNode { + val definition = definitionRepository.findById(configuration.definitionId) + .orElseThrow { ExternalPluginNotFoundException("External plugin definition", configuration.definitionId) } + val masked = (configuration.properties ?: objectMapper.createObjectNode()).deepCopy() + propertyEncryptor.secretFieldNames(definition.configSchema).forEach { masked.remove(it) } + return masked + } + + private fun saveGrantedEndpoints(configurationId: UUID, endpoints: List) { + endpoints.forEach { entry -> + grantedEndpointRepository.save( + ExternalPluginGrantedEndpoint( + id = UUID.randomUUID(), + configurationId = configurationId, + httpMethod = entry.method.uppercase(), + endpointPattern = entry.pattern, + ) + ) + } + } + + private fun saveGrantedEvents(configurationId: UUID, events: List) { + events.forEach { entry -> + grantedEventRepository.save( + ExternalPluginGrantedEvent( + id = UUID.randomUUID(), + configurationId = configurationId, + eventType = entry.eventType, + ) + ) + } + } + + /** + * All-or-nothing parity with endpoints (§3.1): the admin's acknowledgement covers the full + * declared set, recorded as the authoritative subscription list for this configuration. + */ + private fun validateGrantedEventsCoverManifest( + grantedEvents: List, + definition: ExternalPluginDefinition, + ) { + val manifest = definition.manifestJson ?: return + val declared = manifest.get("eventSubscriptions") + + val grantedTypes = grantedEvents.map { it.eventType }.toSet() + val requiredTypes = if (declared != null && declared.isArray) { + declared.mapNotNull { it.asText().takeIf { s -> s.isNotBlank() } }.toSet() + } else { + emptySet() + } + + requireExactGrantMatch("event subscriptions", requiredTypes, grantedTypes) + } + + private fun saveGrantedCapabilities(configurationId: UUID, capabilities: List) { + capabilities.forEach { capability -> + grantedCapabilityRepository.save( + ExternalPluginGrantedCapability( + id = UUID.randomUUID(), + configurationId = configurationId, + capability = capability, + ) + ) + } + } + + private fun validateGrantedCapabilitiesCoverManifest( + grantedCapabilities: List, + definition: ExternalPluginDefinition, + ) { + val manifest = definition.manifestJson ?: return + val declaredCapabilities = manifest.get("permissions")?.get("capabilities") + + val grantedSet = grantedCapabilities.map { it.value }.toSet() + val requiredSet = if (declaredCapabilities != null && declaredCapabilities.isArray) { + declaredCapabilities.mapNotNull { it.asText().takeIf { s -> s.isNotBlank() } }.toSet() + } else { + emptySet() + } + + requireExactGrantMatch("capabilities", requiredSet, grantedSet) + } + + private fun validateGrantedEndpointsCoverManifest( + grantedEndpoints: List, + definition: ExternalPluginDefinition, + ) { + val manifest = definition.manifestJson ?: return + val declaredEndpoints = manifest.get("permissions")?.get("endpoints") + + val grantedKeys = grantedEndpoints.map { "${it.method.uppercase()}:${it.pattern}" }.toSet() + val requiredKeys = if (declaredEndpoints != null && declaredEndpoints.isArray) { + declaredEndpoints.mapNotNull { ep -> + val method = ep.get("method")?.asText() ?: return@mapNotNull null + val pattern = ep.get("pattern")?.asText() ?: return@mapNotNull null + "${method.uppercase()}:$pattern" + }.toSet() + } else { + emptySet() + } + + requireExactGrantMatch("endpoints", requiredKeys, grantedKeys) + } + + /** + * Grants must match the manifest declaration exactly: everything declared has to be granted + * (the admin explicitly acknowledges the plugin's full footprint) and nothing beyond the + * declaration can be granted (a grant the plugin never asked for is always a mistake). + */ + private fun requireExactGrantMatch(subject: String, required: Set, granted: Set) { + val missing = required - granted + if (missing.isNotEmpty()) { + throw IllegalArgumentException( + "All $subject declared in the plugin manifest must be granted. " + + "Missing: ${missing.joinToString(", ")}" + ) + } + val undeclared = granted - required + if (undeclared.isNotEmpty()) { + throw IllegalArgumentException( + "Granted $subject must be declared in the plugin manifest. " + + "Not declared: ${undeclared.joinToString(", ")}" + ) + } + } + + private fun validateAgainstSchema(properties: ObjectNode, schemaNode: com.fasterxml.jackson.databind.node.ObjectNode?) { + if (schemaNode == null || schemaNode.isEmpty) return + val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + val schema = factory.getSchema(schemaNode) + val errors = schema.validate(properties) + if (errors.isNotEmpty()) { + val message = errors.joinToString("; ") { it.message } + throw IllegalArgumentException("Configuration does not match schema: $message") + } + } + + companion object { + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionService.kt new file mode 100644 index 0000000000..ad57ec4d42 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionService.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.exception.ExternalPluginNotFoundException +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +@SkipComponentScan +@Transactional(readOnly = true) +class ExternalPluginDefinitionService( + private val definitionRepository: ExternalPluginDefinitionRepository, +) { + + fun list(): List = definitionRepository.findAll() + + fun get(id: UUID): ExternalPluginDefinition = definitionRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin definition", id) } + + fun getAllByPluginId(pluginId: String): List = + definitionRepository.findAllByPluginId(pluginId) + + /** + * Re-pins a definition whose host package changed after acceptance. The caller must echo the + * exact pending hash it reviewed: this is acceptance of a *specific* package, not of "whatever + * the host happens to serve by now" — if the host changed again since the admin looked, the + * echoed hash no longer matches and the request is rejected until the newest state is reviewed. + */ + @Transactional + fun acceptContent(id: UUID, contentHash: String): ExternalPluginDefinition { + val definition = get(id) + val pending = definition.pendingContentHash + ?: throw IllegalStateException( + "External plugin definition ${definition.pluginId}@${definition.version} " + + "has no pending content change to accept" + ) + require(contentHash == pending) { + "The accepted content hash does not match the pending one — the plugin package " + + "changed again on the host; review the current state before accepting" + } + definition.contentHash = pending + definition.pendingContentHash = null + return definitionRepository.save(definition) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryJob.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryJob.kt new file mode 100644 index 0000000000..64961ab3e3 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryJob.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock +import org.springframework.scheduling.annotation.Scheduled + +// `open` (class and [poll]) because ShedLock's @SchedulerLock interceptor subclasses the bean with +// CGLIB; this class is registered via @Bean, so the kotlin-spring allopen plugin does not open it. +@SkipComponentScan +open class ExternalPluginDiscoveryJob( + private val discoveryService: ExternalPluginDiscoveryService, +) { + + /** + * ShedLock keeps the discovery cycle single-node in a multi-node deployment: without it, every + * node would concurrently health-check, upsert definitions and re-push configurations for the + * same hosts. `lockAtMostFor` caps a crashed holder's lock at 10 minutes (a slow cycle over + * many hosts can legitimately take a while now that each host gets HTTP timeouts). + */ + @Scheduled(fixedRateString = "\${valtimo.external-plugin.polling.rate:PT60S}") + @SchedulerLock( + name = "ExternalPluginDiscoveryJob_poll", + lockAtLeastFor = "PT10S", + lockAtMostFor = "PT10M", + ) + open fun poll() { + discoveryService.discoverAll() + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryService.kt new file mode 100644 index 0000000000..fda0563968 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryService.kt @@ -0,0 +1,290 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.time.Instant +import java.util.UUID + +/** + * Polls every registered host: health check, manifest discovery and configuration re-push. + * + * Transaction discipline: HTTP calls to the host (health, plugin listing, config pushes) run + * **outside** any database transaction; database writes happen in short per-host transactions via + * [transactionTemplate]. A slow or hanging host therefore never pins a database connection or + * transaction open, and one host's failure never rolls back another host's bookkeeping. + */ +@Service +@SkipComponentScan +class ExternalPluginDiscoveryService( + private val hostRepository: ExternalPluginHostRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val configurationService: ExternalPluginConfigurationService, + private val hostService: ExternalPluginHostService, + private val hostClient: ExternalPluginHostClient, + private val transactionTemplate: TransactionTemplate, + private val failureThreshold: Int, +) { + + fun discoverAll() { + hostRepository.findAll().forEach { host -> + try { + pollHost(host) + } catch (e: Exception) { + logger.warn(e) { "External plugin discovery failed for host ${host.id} (${host.baseUrl})" } + } + } + } + + /** + * Polls a single host on demand, outside the periodic cycle. Used right after registering an + * app so its single plugin is discovered and available to configure immediately instead of on + * the next polling tick. Best-effort: any failure is swallowed (an unreachable host is simply + * marked as such by [pollHost]) so registration never fails because discovery could not reach + * the host yet. + */ + fun discoverHost(hostId: UUID) { + val host = hostRepository.findById(hostId).orElse(null) ?: return + try { + pollHost(host) + } catch (e: Exception) { + logger.warn(e) { "External plugin discovery failed for host ${host.id} (${host.baseUrl})" } + } + } + + private fun pollHost(host: ExternalPluginHost) { + // HTTP health probe outside any transaction. + val healthy = hostClient.health(host.baseUrl) + + // Short transaction: record the health-check outcome and status flip. + transactionTemplate.executeWithoutResult { recordHealthCheck(host.id, healthy) } + if (!healthy) return + + val adminToken = hostService.decryptedSecret(host) + // HTTP manifest listing outside any transaction. + val plugins = hostClient.listPlugins(host.baseUrl, adminToken) + + // Short transaction: upsert discovered definitions and mark missing ones. + transactionTemplate.executeWithoutResult { + val seenDefinitionIds = mutableSetOf() + plugins.forEach { manifest -> + val pluginId = manifest.get("pluginId")?.asText() + if (pluginId.isNullOrBlank()) return@forEach + val defId = upsertDefinition(host, pluginId, manifest) + if (defId != null) seenDefinitionIds += defId + } + markMissingDefinitions(host, seenDefinitionIds) + } + + // Config pushes are HTTP calls again — outside any transaction. + syncConfigurations(host) + } + + private fun recordHealthCheck(hostId: UUID, healthy: Boolean) { + val host = hostRepository.findById(hostId).orElse(null) ?: return + host.lastHealthCheck = Instant.now() + if (healthy) { + host.consecutiveFailures = 0 + host.status = ExternalPluginHostStatus.CONNECTED + } else { + host.consecutiveFailures += 1 + if (host.consecutiveFailures >= failureThreshold) { + host.status = ExternalPluginHostStatus.UNREACHABLE + } + } + hostRepository.save(host) + } + + private fun syncConfigurations(host: ExternalPluginHost) { + val definitions = definitionRepository.findAllByHostId(host.id) + if (definitions.isEmpty()) return + + definitions.forEach { definition -> + if (definition.requiresReacceptance) { + // No pushes means no fresh service tokens: the last one the host holds expires + // within its (short) TTL, after which the changed plugin can no longer call back + // into GZAC until an admin re-accepts the new content. + logger.warn { + "Withholding configuration pushes for plugin '${definition.pluginId}@${definition.version}': " + + "package content changed on host ${host.id} and awaits re-acceptance" + } + return@forEach + } + val configs = configurationRepository.findAllByDefinitionId(definition.id) + configs.forEach { config -> + try { + val pushed = configurationService.pushToHost(config, definition, host) + if (!pushed) { + logger.warn { "Failed to push configuration ${config.id} for plugin '${definition.pluginId}' to host ${host.id}" } + } + } catch (e: Exception) { + logger.warn(e) { "Failed to push configuration ${config.id} for plugin '${definition.pluginId}' to host ${host.id}" } + } + } + } + } + + private fun upsertDefinition(host: ExternalPluginHost, pluginId: String, pluginEntry: JsonNode): UUID? { + // Plugin-host returns: {pluginId, version, contentHash, manifest: {pluginId, version, + // translations, ...}}. The manifest carries no top-level name/description — those live + // per-locale under `translations` (see localizedManifestValue). + val manifest = pluginEntry.get("manifest") ?: pluginEntry + val version = pluginEntry.get("version")?.asText() ?: manifest.get("version")?.asText() ?: "0.0.0" + val discoveredContentHash = pluginEntry.get("contentHash")?.asText()?.takeIf { it.isNotBlank() } + + val existing = definitionRepository.findByPluginIdAndVersion(pluginId, version) + if (existing != null && existing.hostId != host.id) { + logger.warn { + "External plugin '$pluginId@$version' already registered on host ${existing.hostId}; ignoring discovery from host ${host.id}" + } + return null + } + + val definition = existing ?: ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = pluginId, + version = version, + hostId = host.id, + baseUrl = "${host.baseUrl}/plugins/$pluginId", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + + // Content pinning: the hash of the package as first discovered is what the admin's + // acceptance covers. Anything else the host serves later under the same pluginId@version is + // a change of the running code, so the definition is flagged and its stored (accepted) + // manifest/schema stay frozen until an admin re-accepts — see acceptContent on the + // definition service. A host without hash support (older host) skips pinning entirely. + if (discoveredContentHash != null) { + val pinnedContentHash = definition.contentHash + when { + pinnedContentHash == null -> definition.contentHash = discoveredContentHash + discoveredContentHash != pinnedContentHash -> { + if (definition.pendingContentHash != discoveredContentHash) { + logger.warn { + "Package content of external plugin '$pluginId@$version' on host ${host.id} changed " + + "(pinned $pinnedContentHash, now $discoveredContentHash) — " + + "flagged for re-acceptance; configuration pushes are withheld" + } + definition.pendingContentHash = discoveredContentHash + } + // The plugin is present on the host, just changed — keep it visible. + definition.status = ExternalPluginDefinitionStatus.AVAILABLE + definition.consecutiveMisses = 0 + definitionRepository.save(definition) + return definition.id + } + definition.pendingContentHash != null -> { + // The host serves the pinned bytes again (e.g. a tampered package was rolled + // back) — the flag has served its purpose. + logger.info { + "Package content of external plugin '$pluginId@$version' matches the pinned hash again; " + + "clearing the re-acceptance flag" + } + definition.pendingContentHash = null + } + } + } + + val newConfigSchema = manifest.get("configurationSchema") as? ObjectNode + warnOnDroppedSecretFlags(definition, newConfigSchema) + + definition.name = localizedManifestValue(manifest, "name") ?: definition.name + definition.description = localizedManifestValue(manifest, "description") ?: definition.description + definition.provider = manifest.get("provider")?.asText() ?: definition.provider + definition.minGzacVersion = manifest.path("compatibility").get("minGzacVersion")?.asText() ?: definition.minGzacVersion + definition.maxGzacVersion = manifest.path("compatibility").get("maxGzacVersion")?.asText() ?: definition.maxGzacVersion + definition.configSchema = newConfigSchema + definition.manifestJson = if (manifest is ObjectNode) manifest.deepCopy() else null + definition.baseUrl = "${host.baseUrl}/plugins/$pluginId" + definition.status = ExternalPluginDefinitionStatus.AVAILABLE + definition.consecutiveMisses = 0 + + definitionRepository.save(definition) + return definition.id + } + + /** + * A property that loses its `x-secret: true` flag between manifest versions silently changes + * from "encrypted at rest, masked in the API" to plain text on the next save. That is almost + * always a plugin-author mistake, so surface it loudly for the operator. + */ + private fun warnOnDroppedSecretFlags(definition: ExternalPluginDefinition, newConfigSchema: ObjectNode?) { + val previousSecrets = secretFieldNames(definition.configSchema) + if (previousSecrets.isEmpty()) return + val droppedSecrets = previousSecrets - secretFieldNames(newConfigSchema) + if (droppedSecrets.isNotEmpty()) { + logger.warn { + "Plugin '${definition.pluginId}@${definition.version}' dropped the x-secret flag from " + + "previously secret propert${if (droppedSecrets.size == 1) "y" else "ies"} " + + "${droppedSecrets.joinToString(", ")} in its new configuration schema — these values " + + "will no longer be encrypted or masked" + } + } + } + + private fun secretFieldNames(schema: JsonNode?): Set { + val schemaProperties = schema?.get("properties") ?: return emptySet() + return schemaProperties.fields().asSequence() + .filter { (_, fieldSchema) -> fieldSchema.get("x-secret")?.asBoolean(false) == true } + .map { (field, _) -> field } + .toSet() + } + + private fun markMissingDefinitions(host: ExternalPluginHost, seenDefinitionIds: Set) { + definitionRepository.findAllByHostId(host.id).forEach { definition -> + if (definition.id in seenDefinitionIds) return@forEach + definition.consecutiveMisses += 1 + if (definition.consecutiveMisses >= failureThreshold) { + definition.status = ExternalPluginDefinitionStatus.UNAVAILABLE + } + definitionRepository.save(definition) + } + } + + /** + * Resolves a localised manifest value (`name`, `description`) from the manifest's per-locale + * `translations` block. The manifest has no top-level `name`/`description`; they live in every + * locale bucket. Prefers the `en` bucket, then falls back to the first declared locale. The + * result is stored on the denormalised `name`/`description` columns the management UI uses as a + * fallback when it cannot localise from the manifest itself. + */ + private fun localizedManifestValue(manifest: JsonNode, key: String): String? { + val translations = manifest.path("translations") + if (!translations.isObject) return null + translations.path("en").path(key).asText("").takeIf { it.isNotBlank() }?.let { return it } + val firstLocale = translations.fields().asSequence().firstOrNull()?.value ?: return null + return firstLocale.path(key).asText("").takeIf { it.isNotBlank() } + } + + companion object { + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostService.kt new file mode 100644 index 0000000000..de846a3a38 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostService.kt @@ -0,0 +1,212 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.EventQueueMode +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostKind +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.exception.ExternalPluginHostInUseException +import com.ritense.externalplugin.exception.ExternalPluginNotFoundException +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedCapabilityRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.net.URI +import java.util.UUID + +@Service +@SkipComponentScan +@Transactional +class ExternalPluginHostService( + private val hostRepository: ExternalPluginHostRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + private val grantedEventRepository: ExternalPluginGrantedEventRepository, + private val grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository, + private val encryptionService: EncryptionService, + private val hostClient: ExternalPluginHostClient, + private val hostUsageResolver: ExternalPluginHostUsageResolver, +) { + + fun list(): List = hostRepository.findAll() + + fun get(id: UUID): ExternalPluginHost = hostRepository.findById(id) + .orElseThrow { ExternalPluginNotFoundException("External plugin host", id) } + + fun decryptedSecret(host: ExternalPluginHost): String = encryptionService.decrypt(host.secret) + + fun register( + name: String, + baseUrl: String, + secret: String, + gzacCallbackBaseUrl: String, + eventBrokerAmqpUrl: String?, + eventBrokerExchange: String?, + eventQueueMode: EventQueueMode = EventQueueMode.LIVE, + eventQueueTtlMs: Long? = null, + kind: ExternalPluginHostKind = ExternalPluginHostKind.PLUGIN_HOST, + ): ExternalPluginHost { + val normalizedBaseUrl = baseUrl.trimEnd('/') + val brokerAmqpUrl = eventBrokerAmqpUrl?.takeIf { it.isNotBlank() } + // The config push delivers the broker AMQP URL and credentials in its body. HMAC binds and + // authenticates that body but does not encrypt it, so a broker may only be configured on a + // host the push can reach over a confidential transport. Registration is the single gate: + // the base URL is immutable afterwards, so no later push can reach an insecure host. + require(brokerAmqpUrl == null || isSecureTransport(normalizedBaseUrl)) { + "Refusing to register host '$normalizedBaseUrl' with event broker credentials over an " + + "unencrypted transport. The configuration push carries the broker AMQP URL and " + + "credentials, so the host must be reachable over HTTPS (or a loopback address for " + + "local development). Enable TLS on the host, or leave the event broker blank to " + + "disable events for configurations on this host." + } + val resolvedTtlMs = resolveEventQueueTtlMs(eventQueueMode, eventQueueTtlMs) + val host = ExternalPluginHost( + id = UUID.randomUUID(), + name = name, + baseUrl = normalizedBaseUrl, + secret = encryptionService.encrypt(secret), + status = ExternalPluginHostStatus.UNREACHABLE, + kind = kind, + gzacCallbackBaseUrl = gzacCallbackBaseUrl.trimEnd('/'), + eventBrokerAmqpUrl = brokerAmqpUrl, + eventBrokerExchange = eventBrokerExchange?.takeIf { it.isNotBlank() }, + eventQueueMode = eventQueueMode, + eventQueueTtlMs = resolvedTtlMs, + ) + return hostRepository.save(host) + } + + /** + * Updates only the per-host event-queue declaration knobs. The base URL, secret, broker URL + * and broker exchange remain immutable — those are the security-sensitive fields. Mode and TTL + * only affect the queue declaration on the plugin-host side; the next configuration push + * propagates the change so the host swaps its queue. + */ + fun updateEventQueue( + hostId: UUID, + eventQueueMode: EventQueueMode, + eventQueueTtlMs: Long?, + ): ExternalPluginHost { + val host = get(hostId) + host.eventQueueMode = eventQueueMode + host.eventQueueTtlMs = resolveEventQueueTtlMs(eventQueueMode, eventQueueTtlMs) + return hostRepository.save(host) + } + + private fun resolveEventQueueTtlMs(mode: EventQueueMode, ttlMs: Long?): Long? = when (mode) { + EventQueueMode.LIVE -> { + require(ttlMs == null) { + "eventQueueTtlMs must be null when eventQueueMode is LIVE (got $ttlMs)." + } + null + } + EventQueueMode.DURABLE -> { + val value = ttlMs ?: DEFAULT_EVENT_QUEUE_TTL_MS + require(value in MIN_EVENT_QUEUE_TTL_MS..MAX_EVENT_QUEUE_TTL_MS) { + "eventQueueTtlMs must be between $MIN_EVENT_QUEUE_TTL_MS (1h) and " + + "$MAX_EVENT_QUEUE_TTL_MS (30d), got $value." + } + value + } + } + + /** + * Exposes what currently references any configuration under this host — BPMN process links, + * external-plugin case tabs and case widgets, and building-block mappings. + * The UI uses this to disable the delete control proactively; the server-side guard in + * [delete] still enforces the same invariant, so an empty list here does not authorise + * deletion — a concurrently created reference between this call and the delete call would + * still surface as an [ExternalPluginHostInUseException]. + */ + @Transactional(readOnly = true) + fun findUsages(hostId: UUID): List = hostUsageResolver.findUsagesForHost(hostId) + + fun delete(hostId: UUID) { + val usages = hostUsageResolver.findUsagesForHost(hostId) + if (usages.isNotEmpty()) { + throw ExternalPluginHostInUseException(hostId, usages) + } + + val definitions = definitionRepository.findAllByHostId(hostId) + for (definition in definitions) { + val configurations = configurationRepository.findAllByDefinitionId(definition.id) + for (configuration in configurations) { + grantedEndpointRepository.deleteAllByConfigurationId(configuration.id) + grantedEventRepository.deleteAllByConfigurationId(configuration.id) + grantedCapabilityRepository.deleteAllByConfigurationId(configuration.id) + } + configurationRepository.deleteAll(configurations) + } + definitionRepository.deleteAll(definitions) + hostRepository.deleteById(hostId) + } + + /** + * `NOT_SUPPORTED`: the upload is a (potentially large/slow) HTTP call to the host and must not + * run inside a database transaction. The host lookup runs non-transactionally, which is fine — + * it is a single read. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + fun uploadPlugin(hostId: UUID, fileName: String, fileBytes: ByteArray, overwrite: Boolean = false): JsonNode { + val host = get(hostId) + // An app *is* its single plugin — it serves its own manifest and accepts no uploads. The UI + // hides the upload affordance for apps; this is the server-side backstop. + require(host.kind != ExternalPluginHostKind.APP) { + "Host $hostId is an app and does not accept plugin uploads; it serves its own plugin." + } + val adminToken = decryptedSecret(host) + return hostClient.uploadPlugin(host.baseUrl, adminToken, fileName, fileBytes, overwrite) + } + + companion object { + private val LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1", "::1") + + /** Default queue inactivity TTL for new DURABLE hosts: 72 hours. */ + const val DEFAULT_EVENT_QUEUE_TTL_MS: Long = 72L * 60 * 60 * 1000 + + /** Minimum allowed TTL: 1 hour. Below this a brief restart can blow away the queue. */ + const val MIN_EVENT_QUEUE_TTL_MS: Long = 60L * 60 * 1000 + + /** Maximum allowed TTL: 30 days. Past this, buffered events are likely stale. */ + const val MAX_EVENT_QUEUE_TTL_MS: Long = 30L * 24 * 60 * 60 * 1000 + + /** + * Whether a host base URL provides a confidential transport for the broker credentials and + * service token carried in a configuration push. HTTPS encrypts the channel end-to-end; a + * loopback address keeps the traffic on the local machine. Plain HTTP to any other host is + * eavesdroppable — HMAC authenticates the push but does not encrypt it. + */ + fun isSecureTransport(baseUrl: String): Boolean { + val uri = runCatching { URI(baseUrl) }.getOrNull() ?: return false + if (uri.scheme?.lowercase() == "https") return true + val host = uri.host?.removeSurrounding("[", "]")?.lowercase() ?: return false + return host in LOOPBACK_HOSTS + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt new file mode 100644 index 0000000000..149777fbba --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt @@ -0,0 +1,288 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case_.service.CaseExternalPluginTabService +import com.ritense.case_.service.CaseExternalPluginWidgetService +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder +import com.ritense.plugin.service.ProcessDefinitionUsageMeta +import com.ritense.plugin.service.ProcessDefinitionUsageMetaResolver +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.Optional +import java.util.UUID + +@Service +@SkipComponentScan +@Transactional(readOnly = true) +class ExternalPluginHostUsageResolver( + private val definitionRepository: ExternalPluginDefinitionRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + /** Shared with the embedded plugin module — resolves process-definition/parent/activity meta. */ + private val processDefinitionUsageMetaResolver: ProcessDefinitionUsageMetaResolver, + /** + * Optional so the module still wires up if the case module's tab service is unavailable; in a + * normal GZAC deployment it is always present (external-plugin depends on case). + */ + private val caseExternalPluginTabService: Optional, + /** + * Optional for the same reason as the tab service: an `external-plugin` case widget references a + * configuration without a process link, so it too must block deletion. + */ + private val caseExternalPluginWidgetService: Optional, + /** + * Optional for the same reason: implemented by the building-block module, which external-plugin + * cannot depend on directly (the SPI lives in `:backend:plugin`). Without it, building-block + * mapping usages simply don't block deletion — matching a deployment without building blocks. + */ + private val buildingBlockMappingUsageFinder: Optional = Optional.empty(), +) { + + fun findUsagesForHost(hostId: UUID): List { + val definitions = definitionRepository.findAllByHostId(hostId) + val configurations = collectConfigurations(definitions) + return buildUsageDtos(configurations) + + buildCaseTabUsageDtos(configurations) + + buildCaseWidgetUsageDtos(configurations) + + buildBuildingBlockMappingUsageDtos(configurations) + + buildDefinitionReferenceUsageDtos(definitions) + } + + fun findUsagesForConfiguration(configurationId: UUID): List { + val configuration = configurationRepository.findById(configurationId).orElse(null) + ?: return emptyList() + return buildUsageDtos(listOf(configuration)) + + buildCaseTabUsageDtos(listOf(configuration)) + + buildCaseWidgetUsageDtos(listOf(configuration)) + + buildBuildingBlockMappingUsageDtos(listOf(configuration)) + } + + /** + * A `case-tab` of an external plugin references the configuration without a process link, so it + * counts as a usage that blocks deletion (system-plan §12). + */ + private fun buildCaseTabUsageDtos(configurations: List): List { + val tabService = caseExternalPluginTabService.orElse(null) ?: return emptyList() + if (configurations.isEmpty()) return emptyList() + return configurations.flatMap { configuration -> + tabService.findUsagesForConfiguration(configuration.id).map { usage -> + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = PluginUsageParentType.CASE, + parentKey = usage.caseDefinitionKey, + parentVersionTag = usage.caseDefinitionVersionTag, + tabKey = usage.tabKey, + tabName = usage.tabName, + ) + } + } + } + + /** + * An `external-plugin` case widget references the configuration without a process link, so it + * counts as a usage that blocks deletion (system-plan §12) — the widget sibling of + * [buildCaseTabUsageDtos]. + */ + private fun buildCaseWidgetUsageDtos(configurations: List): List { + val widgetService = caseExternalPluginWidgetService.orElse(null) ?: return emptyList() + if (configurations.isEmpty()) return emptyList() + return configurations.flatMap { configuration -> + widgetService.findUsagesForConfiguration(configuration.id).map { usage -> + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = PluginUsageParentType.CASE, + parentKey = usage.caseDefinitionKey, + parentVersionTag = usage.caseDefinitionVersionTag, + tabKey = usage.tabKey, + tabName = usage.tabName, + widgetKey = usage.widgetKey, + ) + } + } + } + + private fun buildUsageDtos(configurations: List): List { + if (configurations.isEmpty()) return emptyList() + val configById = configurations.associateBy { it.id } + // Both external-plugin process-link surfaces reference a configuration: service-task actions + // and user-task forms. Each is its own discriminator (a subtype-typed JPA repository only + // returns rows of its own type), so we union both to guard deletion against either usage. + val links = collectUsageLinks(configById.keys) + if (links.isEmpty()) return emptyList() + + val metaCache = mutableMapOf() + + return links.map { link -> + val meta = metaCache.getOrPut(link.processDefinitionId) { + processDefinitionUsageMetaResolver.resolveMeta(link.processDefinitionId) + } + val configuration = configById.getValue(link.configurationId) + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = link.processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = link.activityId, + activityName = processDefinitionUsageMetaResolver.resolveActivityName(meta, link.activityId), + processLinkId = link.id, + ) + } + } + + /** + * A building block references a configuration through its `pluginConfigurationMappings` (on the + * call-activity process link or the case-definition ↔ BB link) rather than through a process + * link's own configuration id, so those mappings must equally block deletion — otherwise the + * mapping would silently dangle and every run of the building block would fail to resolve its + * plugin. + */ + private fun buildBuildingBlockMappingUsageDtos( + configurations: List, + ): List { + val finder = buildingBlockMappingUsageFinder.orElse(null) ?: return emptyList() + if (configurations.isEmpty()) return emptyList() + + val metaCache = mutableMapOf() + return configurations.flatMap { configuration -> + finder.findUsages(configuration.id).map { usage -> + val processDefinitionId = usage.processDefinitionId + if (processDefinitionId != null) { + val meta = metaCache.getOrPut(processDefinitionId) { + processDefinitionUsageMetaResolver.resolveMeta(processDefinitionId) + } + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = usage.activityId, + activityName = usage.activityId?.let { processDefinitionUsageMetaResolver.resolveActivityName(meta, it) }, + processLinkId = usage.processLinkId, + buildingBlockKey = usage.buildingBlockDefinitionKey, + ) + } else { + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = PluginUsageParentType.CASE, + parentKey = usage.caseDefinitionKey, + parentVersionTag = usage.caseDefinitionVersionTag, + buildingBlockKey = usage.buildingBlockDefinitionKey, + ) + } + } + } + } + + /** + * A `BUILDING_BLOCK`-reference process link pins a plugin *definition* (`pluginId@version`), + * not a configuration — it never blocks deleting an individual configuration, but it must block + * deleting the host that serves the definition: without the host, the reference can never + * resolve again, even when no case definition uses the building block yet. The shared + * [PluginUsageDto] requires a configuration identity, so the definition stands in: + * `configurationId` carries the definition id and `configurationTitle` the `pluginId@version` + * pair the link is pinned to. + */ + private fun buildDefinitionReferenceUsageDtos( + definitions: List, + ): List { + if (definitions.isEmpty()) return emptyList() + + val links = processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + definitions.map { it.pluginId }.toSet(), + ) + if (links.isEmpty()) return emptyList() + + val definitionByKeyAndVersion = definitions.associateBy { it.pluginId to it.version } + val metaCache = mutableMapOf() + + return links.mapNotNull { link -> + val reference = link.pluginConfigurationReference + val definition = definitionByKeyAndVersion[reference.pluginDefinitionKey to reference.pluginDefinitionVersion] + ?: return@mapNotNull null + val meta = metaCache.getOrPut(link.processDefinitionId) { + processDefinitionUsageMetaResolver.resolveMeta(link.processDefinitionId) + } + PluginUsageDto( + configurationId = definition.id, + configurationTitle = "${definition.pluginId}@${definition.version}", + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = link.processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = link.activityId, + activityName = processDefinitionUsageMetaResolver.resolveActivityName(meta, link.activityId), + processLinkId = link.id, + ) + } + } + + private fun collectUsageLinks(configurationIds: Collection): List { + // externalPluginConfigurationId is nullable on the entity (BUILDING_BLOCK references, Phase + // 2, carry no fixed config id) but findAllByExternalPluginConfigurationIdIn only ever + // returns rows whose id is one of the (non-null) ids queried for — the mapNotNull is a type + //-level formality, not an expected filter. + val actionLinks = processLinkRepository.findAllByExternalPluginConfigurationIdIn(configurationIds) + .mapNotNull { link -> + link.externalPluginConfigurationId?.let { UsageLink(link.id, link.processDefinitionId, link.activityId, it) } + } + val taskFormLinks = taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(configurationIds) + .map { UsageLink(it.id, it.processDefinitionId, it.activityId, it.externalPluginConfigurationId) } + return actionLinks + taskFormLinks + } + + private fun collectConfigurations(definitions: List): List { + if (definitions.isEmpty()) return emptyList() + return definitions.flatMap { configurationRepository.findAllByDefinitionId(it.id) } + } + + /** + * Configuration-referencing process link, unified across the external-plugin surfaces (service-task + * action + user-task form) so the delete guard treats both identically. + */ + private data class UsageLink( + val id: UUID, + val processDefinitionId: String, + val activityId: String, + val configurationId: UUID, + ) +} \ No newline at end of file diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageService.kt new file mode 100644 index 0000000000..6e13ab37ba --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageService.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.web.rest.dto.ExternalPluginMenuPageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * Lists the `page` bundles of every activated (`AVAILABLE`) external-plugin configuration so the + * menu-configuration builder can offer them as a "Plugin pages" catalog category. Each entry carries + * the resolved bundle URL (via the shared [ExternalPluginBundleUrlResolver]) plus the title/icon the + * builder renders. No role field — access is PBAC at render time (the page route mints a downscoped + * user token), so this list is intentionally unfiltered. + */ +@Service +@SkipComponentScan +@Transactional(readOnly = true) +class ExternalPluginMenuPageService( + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val bundleUrlResolver: ExternalPluginBundleUrlResolver, +) { + + fun getMenuPages(): List { + return configurationRepository.findAll().flatMap { configuration -> + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) + ?: return@flatMap emptyList() + if (definition.status != ExternalPluginDefinitionStatus.AVAILABLE) return@flatMap emptyList() + + val bundles = definition.manifestJson?.get("frontendBundles") + if (bundles == null || !bundles.isArray) return@flatMap emptyList() + + val translations = definition.manifestJson?.get("translations") + + bundles.filter { it.get("type")?.asText() == PAGE_TYPE }.map { bundle -> + val bundleKey = bundle.get("key")?.asText() + val title = bundle.get("title")?.asText() + ExternalPluginMenuPageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + bundleKey = bundleKey, + bundleUrl = bundleUrlResolver.resolve(configuration.id, PAGE_TYPE, bundleKey), + title = title, + titleTranslations = resolveTitleTranslations(translations, title), + icon = bundle.get("icon")?.asText(), + ) + } + } + } + + /** + * Resolves [titleKey] across every locale bucket of the manifest's `translations` block, e.g. + * `{ en: { "page.overview.title": "Overview" } }` → `{ "en": "Overview" }`. Empty when there are + * no translations or [titleKey] is itself a literal not present in any bucket. + */ + private fun resolveTitleTranslations(translations: JsonNode?, titleKey: String?): Map { + if (translations == null || !translations.isObject || titleKey.isNullOrBlank()) return emptyMap() + val result = linkedMapOf() + translations.fields().forEach { (locale, bucket) -> + bucket.get(titleKey)?.takeIf { it.isTextual }?.let { result[locale] = it.asText() } + } + return result + } + + companion object { + private const val PAGE_TYPE = "page" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenService.kt new file mode 100644 index 0000000000..675e617670 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenService.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.security.ExternalPluginServiceTokenKeyProvider +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant +import java.util.Date + +/** + * Issues short-lived JWTs that authenticate the plugin host (or a URL plugin) when calling back + * into GZAC on behalf of a specific external plugin configuration. + * + * The token carries no roles. Endpoint access is gated by [com.ritense.externalplugin.security.ExternalPluginEndpointAllowlistFilter]. + * + * The default TTL is deliberately a small multiple of the discovery polling rate (60s): the poll + * re-pushes a fresh token to the host on every cycle, so a leaked token is only usable for minutes + * rather than a day. Each token also carries the configuration's [ExternalPluginConfiguration + * .tokenGeneration]; bumping that counter (the revoke-tokens management endpoint) invalidates every + * outstanding token at once — see [com.ritense.externalplugin.security.ExternalPluginServiceTokenAuthenticator]. + */ +@Service +@SkipComponentScan +class ExternalPluginServiceTokenService( + private val keyProvider: ExternalPluginServiceTokenKeyProvider, + private val tokenTtl: Duration = DEFAULT_TTL, +) { + + fun issue(configuration: ExternalPluginConfiguration, definition: ExternalPluginDefinition): String { + val now = Instant.now() + + return Jwts.builder() + .subject("external-plugin:${definition.pluginId}:${configuration.id}") + .claim(ExternalPluginServiceTokenKeyProvider.TYPE_CLAIM, ExternalPluginServiceTokenKeyProvider.TOKEN_TYPE) + .claim(PLUGIN_CONFIG_ID_CLAIM, configuration.id.toString()) + .claim(PLUGIN_ID_CLAIM, definition.pluginId) + .claim(PLUGIN_VERSION_CLAIM, definition.version) + .claim(TOKEN_GENERATION_CLAIM, configuration.tokenGeneration) + .issuer(ISSUER) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(tokenTtl))) + .signWith(keyProvider.signingKey, Jwts.SIG.HS256) + .compact() + } + + companion object { + const val PLUGIN_CONFIG_ID_CLAIM = "plugin_config_id" + const val PLUGIN_ID_CLAIM = "plugin_id" + const val PLUGIN_VERSION_CLAIM = "plugin_version" + const val TOKEN_GENERATION_CLAIM = "token_generation" + const val ISSUER = "valtimo-gzac" + + val DEFAULT_TTL: Duration = Duration.ofMinutes(10) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenService.kt new file mode 100644 index 0000000000..9145862800 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenService.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant +import java.util.Date +import java.util.UUID + +/** + * Issues short-lived JWTs that let an external plugin's iframe read Valtimo data **on behalf of the + * logged-in user**, through the Angular parent-proxy. The token freezes the user's login + roles for + * at most [maxTtl] so GZAC can evaluate PBAC without a Keycloak round-trip on every proxied call. + * + * Crucially — unlike [ExternalPluginServiceTokenService] — the resulting authentication is a *real + * user* (not a system principal) and the recognising filter does **not** run without authorization: + * PBAC stays fully active. Reach is then intersected with the configuration's granted-endpoint + * allowlist. + */ +@Service +@SkipComponentScan +class ExternalPluginUserTokenService( + private val keyProvider: ExternalPluginUserTokenKeyProvider, + tokenTtl: Duration = DEFAULT_TTL, +) { + + /** Hard cap: a downscoped user token is never longer-lived than [MAX_TTL]. */ + private val ttl: Duration = if (tokenTtl > MAX_TTL) MAX_TTL else tokenTtl + + fun issue( + userLogin: String, + roles: List, + configurationId: UUID, + tokenGeneration: Long, + ): IssuedUserToken { + require(userLogin.isNotBlank()) { "userLogin must not be blank" } + val now = Instant.now() + val expiresAt = now.plus(ttl) + + val token = Jwts.builder() + .subject(userLogin) + .claim(ExternalPluginUserTokenKeyProvider.TYPE_CLAIM, ExternalPluginUserTokenKeyProvider.TOKEN_TYPE) + .claim(ROLES_CLAIM, roles) + .claim(PLUGIN_CONFIG_ID_CLAIM, configurationId.toString()) + .claim(TOKEN_GENERATION_CLAIM, tokenGeneration) + .issuer(ISSUER) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(keyProvider.signingKey, Jwts.SIG.HS256) + .compact() + + return IssuedUserToken(token, expiresAt) + } + + companion object { + const val ROLES_CLAIM = "roles" + const val PLUGIN_CONFIG_ID_CLAIM = "plugin_config_id" + + /** + * The configuration's revocation counter at mint time. Validated against the current value + * on every use, so bumping the configuration's generation also kills outstanding user + * tokens — not just service tokens. + */ + const val TOKEN_GENERATION_CLAIM = "token_generation" + const val ISSUER = "valtimo-gzac" + + val DEFAULT_TTL: Duration = Duration.ofMinutes(15) + val MAX_TTL: Duration = Duration.ofMinutes(15) + } +} + +data class IssuedUserToken( + val token: String, + val expiresAt: Instant, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/PluginPropertyEncryptor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/PluginPropertyEncryptor.kt new file mode 100644 index 0000000000..d9d5b4194e --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/PluginPropertyEncryptor.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.service.EncryptionService +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Component + +/** + * Walks a configuration JSON Schema, locates fields marked with `x-secret: true`, and encrypts / + * decrypts the corresponding values in a properties payload using the existing + * [EncryptionService]. Only top-level fields are inspected — nested objects and arrays are not + * recursed. + */ +@Component +@SkipComponentScan +class PluginPropertyEncryptor( + private val encryptionService: EncryptionService, +) { + + fun encryptSecretFields(properties: ObjectNode, schema: JsonNode?): ObjectNode { + secretFieldNames(schema).forEach { field -> + val value = properties.get(field) + if (value != null && value.isTextual && value.asText().isNotEmpty()) { + properties.put(field, encryptionService.encrypt(value.asText())) + } + } + return properties + } + + fun decryptSecretFields(properties: ObjectNode, schema: JsonNode?): ObjectNode { + secretFieldNames(schema).forEach { field -> + val value = properties.get(field) + if (value != null && value.isTextual && value.asText().isNotEmpty()) { + properties.put(field, encryptionService.decrypt(value.asText())) + } + } + return properties + } + + fun secretFieldNames(schema: JsonNode?): Set { + val schemaProperties = schema?.get("properties") ?: return emptySet() + val secrets = mutableSetOf() + schemaProperties.fieldNames().forEachRemaining { field -> + val fieldSchema = schemaProperties.get(field) + if (fieldSchema?.get("x-secret")?.asBoolean(false) == true) { + secrets += field + } + } + return secrets + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResource.kt new file mode 100644 index 0000000000..079b14053c --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResource.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.authorization.annotation.RunWithoutAuthorization +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import java.net.URI +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Lists the origins (`scheme://host[:port]`) of all registered external-plugin hosts so the frontend + * can add them to its Content-Security-Policy (`frame-src`/`connect-src`) before inserting the CSP + * meta tag. A non-management `/api/v1/...` path gated `.authenticated()`: every user who renders a + * plugin tab, task form or page needs these origins, and an origin exposes no secret — host admin + * tokens, broker URLs and configuration data remain behind the ADMIN-only management endpoints. + */ +@RestController +@SkipComponentScan +@RequestMapping("/api/v1/external-plugin", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginHostOriginsResource( + private val hostService: ExternalPluginHostService, +) { + + @EndpointDescription( + en = "List external plugin host origins", + nl = "Externe-pluginhostorigins ophalen", + ) + // Same bypass as the management listHosts endpoint: host rows have no PBAC spec; access is + // gated by the security configurer, and this endpoint only exposes derived origins. + @RunWithoutAuthorization + @GetMapping("/host-origins") + fun getHostOrigins(): ResponseEntity> { + val origins = hostService.list() + .mapNotNull { originOf(it.baseUrl) } + .distinct() + .sorted() + return ResponseEntity.ok(origins) + } + + private fun originOf(baseUrl: String): String? { + val uri = runCatching { URI(baseUrl) }.getOrNull() ?: return null + val scheme = uri.scheme ?: return null + val host = uri.host ?: return null + return if (uri.port == -1) "$scheme://$host" else "$scheme://$host:${uri.port}" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginManagementResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginManagementResource.kt new file mode 100644 index 0000000000..c5a4671734 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginManagementResource.kt @@ -0,0 +1,578 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.annotation.RunWithoutAuthorization +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.compatibility.CompatibilityResult +import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker +import com.ritense.externalplugin.compatibility.PluginPackageInspector +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.service.EndpointDescriptionService +import com.ritense.externalplugin.service.EndpointQuery +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginDiscoveryService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.externalplugin.web.rest.dto.AcceptContentRequest +import com.ritense.externalplugin.web.rest.dto.ConfigurationCreateRequest +import com.ritense.externalplugin.web.rest.dto.ConfigurationDetailResponse +import com.ritense.externalplugin.web.rest.dto.ConfigurationResponse +import com.ritense.externalplugin.web.rest.dto.ConfigurationUpdateRequest +import com.ritense.externalplugin.web.rest.dto.DefinitionResponse +import com.ritense.externalplugin.web.rest.dto.GrantedCapabilityResponse +import com.ritense.externalplugin.web.rest.dto.GrantedEndpointResponse +import com.ritense.externalplugin.web.rest.dto.GrantedEventResponse +import com.ritense.externalplugin.web.rest.dto.HostCreateRequest +import com.ritense.externalplugin.web.rest.dto.HostDefaultsResponse +import com.ritense.externalplugin.web.rest.dto.HostEventQueueUpdateRequest +import com.ritense.externalplugin.web.rest.dto.HostResponse +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import org.springframework.core.env.Environment +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.client.HttpStatusCodeException +import org.springframework.web.client.ResourceAccessException +import org.springframework.web.multipart.MultipartFile +import java.util.UUID + +@Controller +@SkipComponentScan +@RequestMapping("/api/management/v1/external-plugin", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginManagementResource( + private val hostService: ExternalPluginHostService, + private val definitionService: ExternalPluginDefinitionService, + private val configurationService: ExternalPluginConfigurationService, + private val hostClient: ExternalPluginHostClient, + private val endpointDescriptionService: EndpointDescriptionService, + private val discoveryService: ExternalPluginDiscoveryService, + private val environment: Environment, + private val compatibilityChecker: GzacCompatibilityChecker, + private val pluginPackageInspector: PluginPackageInspector, + private val objectMapper: ObjectMapper, +) { + + @RunWithoutAuthorization + @EndpointDescription( + en = "List external plugin hosts", + nl = "Externe-pluginhosts ophalen", + ) + @GetMapping("/host") + fun listHosts(): ResponseEntity> = + ResponseEntity.ok(hostService.list().map(HostResponse::from)) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Register an external plugin host", + nl = "Externe-pluginhost registreren", + ) + @PostMapping("/host") + fun createHost(@RequestBody request: HostCreateRequest): ResponseEntity { + val host = hostService.register( + request.name, + request.baseUrl, + request.secret, + request.gzacCallbackBaseUrl, + resolveBrokerAmqpUrl(request.eventBrokerAmqpUrl), + request.eventBrokerExchange, + request.eventQueueMode, + request.eventQueueTtlMs, + request.kind, + ) + + runCatching { discoveryService.discoverHost(host.id) } + return ResponseEntity.status(HttpStatus.CREATED).body(HostResponse.from(host)) + } + + /** + * Narrowly-scoped update for the per-host event-queue declaration. baseUrl/secret/broker stay + * immutable; only mode and TTL are mutable. Triggers an immediate re-discovery so the host's + * `EventConsumerManager` swaps its queue without waiting for the next polling tick — best-effort + * because the periodic discovery cycle will reconcile anyway. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Update a host's event-queue mode and TTL", + nl = "Event-queue-modus en TTL van host bijwerken", + ) + @PatchMapping("/host/{hostId}/event-queue") + fun updateHostEventQueue( + @PathVariable hostId: UUID, + @RequestBody request: HostEventQueueUpdateRequest, + ): ResponseEntity { + val host = hostService.updateEventQueue( + hostId, + request.eventQueueMode, + request.eventQueueTtlMs, + ) + runCatching { discoveryService.discoverAll() } + return ResponseEntity.ok(HostResponse.from(host)) + } + + /** + * Suggested defaults for the add-host form, derived from existing system state so no env vars + * are required: + * - GZAC callback URL: `http://localhost:{server.port}` — the backend's own port. We deliberately + * do **not** use the incoming request URL: the admin reaches GZAC through the Angular dev + * proxy on port 4200 (or a reverse proxy in production), neither of which is the URL the + * plugin host should call back on. The host typically lives on the same Docker network as the + * backend and reaches it on its native port. Operators override per-host in the UI for + * non-local topologies. + * - Broker AMQP URL: built from `spring.rabbitmq.*` (GZAC's own broker view). + * - Broker exchange: GZAC's outbox publisher exchange. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Get add-host form defaults", + nl = "Standaardwaarden voor host ophalen", + ) + @GetMapping("/host-defaults") + fun hostDefaults(): ResponseEntity { + val serverPort = environment.getProperty("server.port", Int::class.java, 8080) + val gzacCallbackBaseUrl = "http://localhost:$serverPort" + + val eventBrokerExchange = environment.getProperty( + "valtimo.outbox.publisher.rabbitmq.exchange", + "valtimo-events", + ) + + return ResponseEntity.ok( + HostDefaultsResponse( + gzacCallbackBaseUrl = gzacCallbackBaseUrl, + // Credentials are never sent to the browser: the userinfo is redacted here and + // resolveBrokerAmqpUrl substitutes the real credentials server-side when the + // redacted default comes back on host registration. + eventBrokerAmqpUrl = HostResponse.redactAmqpUserInfo(defaultBrokerAmqpUrl())!!, + eventBrokerExchange = eventBrokerExchange, + defaultEventQueueTtlMs = ExternalPluginHostService.DEFAULT_EVENT_QUEUE_TTL_MS, + minEventQueueTtlMs = ExternalPluginHostService.MIN_EVENT_QUEUE_TTL_MS, + maxEventQueueTtlMs = ExternalPluginHostService.MAX_EVENT_QUEUE_TTL_MS, + ) + ) + } + + /** The broker AMQP URL GZAC itself uses, built from `spring.rabbitmq.*` — full credentials. */ + private fun defaultBrokerAmqpUrl(): String { + val rabbitHost = environment.getProperty("spring.rabbitmq.host", "localhost") + val rabbitPort = environment.getProperty("spring.rabbitmq.port", Int::class.java, 5672) + val rabbitUsername = environment.getProperty("spring.rabbitmq.username", "guest") + val rabbitPassword = environment.getProperty("spring.rabbitmq.password", "guest") + val rabbitVirtualHost = environment.getProperty("spring.rabbitmq.virtual-host", "/") + val vhostPath = if (rabbitVirtualHost == "/") "" else "/$rabbitVirtualHost" + return "amqp://$rabbitUsername:$rabbitPassword@$rabbitHost:$rabbitPort$vhostPath" + } + + /** + * Accepts full AMQP URLs as-is. When the redacted default from [hostDefaults] is echoed back + * (userinfo `***`), the real credentials from `spring.rabbitmq.*` are substituted server-side + * so a round-tripped redacted URL never ends up stored. + */ + private fun resolveBrokerAmqpUrl(requested: String?): String? { + if (requested.isNullOrBlank()) return requested + val redactedDefault = HostResponse.redactAmqpUserInfo(defaultBrokerAmqpUrl()) + return if (requested == redactedDefault) defaultBrokerAmqpUrl() else requested + } + + /** + * Lets the UI render the host list with an accurate "delete blocked because…" state without + * having to attempt the delete and parse a 409. The server-side guard in + * [ExternalPluginHostService.delete] remains authoritative — this endpoint is advisory only. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "List usages of an external plugin host", + nl = "Gebruik van externe-pluginhost ophalen", + ) + @GetMapping("/host/{hostId}/usages") + fun listHostUsages(@PathVariable hostId: UUID): ResponseEntity> = + ResponseEntity.ok(hostService.findUsages(hostId)) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Delete an external plugin host", + nl = "Externe-pluginhost verwijderen", + ) + @DeleteMapping("/host/{hostId}") + fun deleteHost(@PathVariable hostId: UUID): ResponseEntity { + hostService.delete(hostId) + return ResponseEntity.noContent().build() + } + + /** + * Uploads a plugin package to the host. Before forwarding the package, GZAC peeks at the + * manifest's `compatibility` range and refuses an incompatible plugin with `409 Conflict` plus + * the version details, unless `force=true`. The operator confirms the warning in the UI, which + * re-issues the request with `force=true` to proceed regardless. A compatible (or + * undeterminable) plugin uploads straight through. + * + * A package whose `pluginId@version` already exists on the host is refused with `409 Conflict` + * carrying `code=PLUGIN_VERSION_EXISTS`, both content hashes and the uploaded manifest's + * requested permissions — the UI shows those for re-review and re-issues the request with + * `overwrite=true` once the admin confirms. After a confirmed overwrite the new content hash + * is pinned and every configuration of the definition is re-granted to exactly the new + * declared permission sets ([ExternalPluginConfigurationService.applyApprovedOverwrite]). + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Upload a plugin package to a host", + nl = "Pluginpakket naar host uploaden", + ) + @PostMapping("/host/{hostId}/upload", consumes = ["multipart/form-data"]) + fun uploadPlugin( + @PathVariable hostId: UUID, + @RequestParam("file") file: MultipartFile, + @RequestParam(name = "force", required = false, defaultValue = "false") force: Boolean, + @RequestParam(name = "overwrite", required = false, defaultValue = "false") overwrite: Boolean = false, + ): ResponseEntity { + val fileBytes = file.bytes + if (!force) { + val range = pluginPackageInspector.readCompatibilityRange(fileBytes) + if (range != null) { + val compatibility = compatibilityChecker.check(range.minGzacVersion, range.maxGzacVersion) + if (!compatibility.compatible) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(incompatibilityBody(compatibility)) + } + } + } + val result = try { + hostService.uploadPlugin(hostId, file.originalFilename ?: "plugin.zip", fileBytes, overwrite) + } catch (e: HttpStatusCodeException) { + val hostBody = parseJsonOrNull(e.responseBodyAsString) + if (e.statusCode == HttpStatus.CONFLICT && hostBody?.get("code")?.asText() == PLUGIN_VERSION_EXISTS_CODE) { + // Enrich with the uploaded manifest's requested permissions so the UI can render + // the re-review screen without parsing the zip client-side. + return ResponseEntity.status(HttpStatus.CONFLICT).body(versionExistsBody(hostBody, fileBytes)) + } + // Any other rejection (bad package, …) or failure — relay the host's status and error + // body instead of surfacing a raw 500. + return ResponseEntity.status(e.statusCode) + .body(uploadErrorBody("Plugin host rejected the upload", e.responseBodyAsString)) + } catch (e: ResourceAccessException) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(uploadErrorBody("Plugin host is unreachable", e.message)) + } + if (overwrite) { + val pluginId = result.get("pluginId")?.asText() + val version = result.get("version")?.asText() + if (pluginId != null && version != null) { + configurationService.applyApprovedOverwrite( + pluginId, + version, + result.get("contentHash")?.asText()?.takeIf { it.isNotBlank() }, + pluginPackageInspector.readManifest(fileBytes), + ) + } + } + discoveryService.discoverAll() + return ResponseEntity.status(HttpStatus.CREATED).body(result) + } + + @RunWithoutAuthorization + @EndpointDescription( + en = "List external plugin definitions", + nl = "Externe-plugindefinities ophalen", + ) + @GetMapping("/definition") + fun listDefinitions(): ResponseEntity> = + ResponseEntity.ok(definitionService.list().map(::toDefinitionResponse)) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Get an external plugin definition", + nl = "Externe-plugindefinitie ophalen", + ) + @GetMapping("/definition/{definitionId}") + fun getDefinition(@PathVariable definitionId: UUID): ResponseEntity = + ResponseEntity.ok(toDefinitionResponse(definitionService.get(definitionId))) + + /** + * Re-accepts a definition whose package content changed on its host after the original + * acceptance (see `requiresReacceptance` on the definition response). The request echoes the + * pending hash the admin reviewed; on success the new hash is pinned and an immediate + * re-discovery refreshes the frozen manifest data and resumes configuration pushes. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Accept changed plugin package content", + nl = "Gewijzigde plugininhoud accepteren", + ) + @PostMapping("/definition/{definitionId}/accept-content") + fun acceptDefinitionContent( + @PathVariable definitionId: UUID, + @RequestBody request: AcceptContentRequest, + ): ResponseEntity { + val definition = definitionService.acceptContent(definitionId, request.contentHash) + runCatching { discoveryService.discoverHost(definition.hostId) } + return ResponseEntity.ok(toDefinitionResponse(definitionService.get(definitionId))) + } + + @RunWithoutAuthorization + @EndpointDescription( + en = "List external plugin configurations", + nl = "Externe-pluginconfiguraties ophalen", + ) + @GetMapping("/configuration") + fun listConfigurations( + @RequestParam(required = false) definitionId: UUID?, + ): ResponseEntity> = + ResponseEntity.ok(configurationService.list(definitionId).map(ConfigurationResponse::from)) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Get an external plugin configuration", + nl = "Externe-pluginconfiguratie ophalen", + ) + @GetMapping("/configuration/{configurationId}") + fun getConfiguration( + @PathVariable configurationId: UUID, + ): ResponseEntity { + val configuration = configurationService.get(configurationId) + // Secrets never travel to the browser: x-secret fields are omitted (see maskedProperties); + // on update an absent/blank secret means "unchanged". + val maskedProperties = configurationService.maskedProperties(configuration) + val grantedEndpoints = configurationService.getGrantedEndpoints(configurationId) + val grantedEvents = configurationService.getGrantedEvents(configurationId) + val grantedCapabilities = configurationService.getGrantedCapabilities(configurationId) + return ResponseEntity.ok( + ConfigurationDetailResponse( + id = configuration.id, + definitionId = configuration.definitionId, + title = configuration.title, + properties = maskedProperties, + grantedEndpoints = grantedEndpoints.map(GrantedEndpointResponse::from), + grantedEvents = grantedEvents.map(GrantedEventResponse::from), + grantedCapabilities = grantedCapabilities.map(GrantedCapabilityResponse::from), + createdAt = configuration.createdAt, + ) + ) + } + + @RunWithoutAuthorization + @EndpointDescription( + en = "Create an external plugin configuration", + nl = "Externe-pluginconfiguratie aanmaken", + ) + @PostMapping("/configuration") + fun createConfiguration( + @RequestBody request: ConfigurationCreateRequest, + ): ResponseEntity { + val configuration = configurationService.create( + request.definitionId, + request.title, + request.properties, + request.grantedEndpoints, + request.grantedEvents, + request.grantedCapabilities, + ) + return ResponseEntity.status(HttpStatus.CREATED).body(ConfigurationResponse.from(configuration)) + } + + @RunWithoutAuthorization + @EndpointDescription( + en = "Update an external plugin configuration", + nl = "Externe-pluginconfiguratie bijwerken", + ) + @PutMapping("/configuration/{configurationId}") + fun updateConfiguration( + @PathVariable configurationId: UUID, + @RequestBody request: ConfigurationUpdateRequest, + ): ResponseEntity { + val configuration = configurationService.update( + configurationId, + request.title, + request.properties, + request.grantedEndpoints, + ) + return ResponseEntity.ok(ConfigurationResponse.from(configuration)) + } + + /** + * Mirrors `listHostUsages` but scoped to a single configuration. Lets the management UI + * pre-emptively disable the delete control on a configuration whose process links would + * otherwise cause a 409. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "List usages of an external plugin configuration", + nl = "Gebruik van externe-pluginconfiguratie ophalen", + ) + @GetMapping("/configuration/{configurationId}/usages") + fun listConfigurationUsages( + @PathVariable configurationId: UUID, + ): ResponseEntity> = + ResponseEntity.ok(configurationService.findUsages(configurationId)) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Get logs for an external plugin configuration", + nl = "Logs van externe-pluginconfiguratie ophalen", + ) + @GetMapping("/configuration/{configurationId}/logs") + fun getConfigurationLogs( + @PathVariable configurationId: UUID, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "25") size: Int, + @RequestParam(required = false) level: String?, + @RequestParam(required = false) source: String?, + ): ResponseEntity { + val configuration = configurationService.get(configurationId) + val definition = definitionService.get(configuration.definitionId) + val host = hostService.get(definition.hostId) + val adminToken = hostService.decryptedSecret(host) + val result = hostClient.getConfigurationLogs( + host.baseUrl, adminToken, configurationId.toString(), page, size, level, source + ) + return ResponseEntity.ok(result) + } + + @RunWithoutAuthorization + @EndpointDescription( + en = "Delete an external plugin configuration", + nl = "Externe-pluginconfiguratie verwijderen", + ) + @DeleteMapping("/configuration/{configurationId}") + fun deleteConfiguration( + @PathVariable configurationId: UUID, + ): ResponseEntity { + configurationService.delete(configurationId) + return ResponseEntity.noContent().build() + } + + /** + * Incident off-switch: instantly invalidates every service and user token minted for this + * configuration (they carry a generation counter that must match the configuration's current + * one). The configuration itself keeps existing — unlike deletion, which the in-use guards + * rightly resist — and a fresh token of the new generation is pushed to the host right after, + * so a legitimate host recovers without waiting for the next discovery cycle. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Revoke all tokens of an external plugin configuration", + nl = "Alle tokens van een externe-pluginconfiguratie intrekken", + ) + @PostMapping("/configuration/{configurationId}/revoke-tokens") + fun revokeConfigurationTokens( + @PathVariable configurationId: UUID, + ): ResponseEntity = + ResponseEntity.ok(ConfigurationResponse.from(configurationService.revokeTokens(configurationId))) + + @RunWithoutAuthorization + @EndpointDescription( + en = "Resolve endpoint descriptions", + nl = "Endpoint-beschrijvingen ophalen", + ) + @PostMapping("/endpoint-descriptions") + fun resolveEndpointDescriptions( + @RequestBody endpoints: List, + @RequestParam(defaultValue = "en") locale: String, + ): ResponseEntity> = + ResponseEntity.ok(endpointDescriptionService.resolveDescriptions(endpoints, locale)) + + private fun toDefinitionResponse(definition: ExternalPluginDefinition): DefinitionResponse { + val compatibility = compatibilityChecker.check(definition.minGzacVersion, definition.maxGzacVersion) + return DefinitionResponse.from(definition, compatibility) + } + + private fun incompatibilityBody(compatibility: CompatibilityResult): JsonNode = + objectMapper.createObjectNode().apply { + put("incompatible", true) + put("compatible", false) + put("currentGzacVersion", compatibility.currentGzacVersion) + put("minGzacVersion", compatibility.minGzacVersion) + put("maxGzacVersion", compatibility.maxGzacVersion) + } + + /** + * The 409 body for an upload targeting an existing pluginId@version: the host's hashes (so the + * UI can tell an identical re-upload apart from different content) plus the uploaded + * manifest's requested endpoint/event/capability sets for the permission re-review screen. + */ + private fun versionExistsBody(hostBody: JsonNode, fileBytes: ByteArray): JsonNode { + val manifest = pluginPackageInspector.readManifest(fileBytes) + return objectMapper.createObjectNode().apply { + put("code", PLUGIN_VERSION_EXISTS_CODE) + put("error", hostBody.get("error")?.asText() ?: "Plugin version already exists") + hostBody.get("message")?.asText()?.let { put("message", it) } + hostBody.get("currentContentHash")?.takeIf { it.isTextual } + ?.let { put("currentContentHash", it.asText()) } + hostBody.get("uploadedContentHash")?.takeIf { it.isTextual } + ?.let { put("uploadedContentHash", it.asText()) } + manifest?.get("pluginId")?.asText()?.let { put("pluginId", it) } + manifest?.get("version")?.asText()?.let { put("version", it) } + set( + "requestedEndpoints", + objectMapper.createArrayNode().apply { + manifest?.get("permissions")?.get("endpoints")?.takeIf { it.isArray }?.forEach { endpoint -> + val method = endpoint.get("method")?.asText()?.takeIf { it.isNotBlank() } + val pattern = endpoint.get("pattern")?.asText()?.takeIf { it.isNotBlank() } + if (method != null && pattern != null) { + addObject().put("method", method).put("pattern", pattern) + } + } + }, + ) + set( + "requestedEventSubscriptions", + objectMapper.createArrayNode().apply { + manifest?.get("eventSubscriptions")?.takeIf { it.isArray }?.forEach { event -> + event.asText().takeIf { it.isNotBlank() }?.let { add(it) } + } + }, + ) + set( + "requestedCapabilities", + objectMapper.createArrayNode().apply { + manifest?.get("permissions")?.get("capabilities")?.takeIf { it.isArray }?.forEach { capability -> + capability.asText().takeIf { it.isNotBlank() }?.let { add(it) } + } + }, + ) + } + } + + private fun parseJsonOrNull(body: String?): JsonNode? = if (body.isNullOrBlank()) null else try { + objectMapper.readTree(body) + } catch (_: Exception) { + null + } + + private fun uploadErrorBody(message: String, detail: String?): JsonNode = + objectMapper.createObjectNode().apply { + put("error", message) + if (!detail.isNullOrBlank()) put("detail", detail) + } + + companion object { + /** Host 409 code for an upload naming an already-existing pluginId@version. */ + const val PLUGIN_VERSION_EXISTS_CODE = "PLUGIN_VERSION_EXISTS" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginMenuPageResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginMenuPageResource.kt new file mode 100644 index 0000000000..9b8b11bc67 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginMenuPageResource.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.externalplugin.service.ExternalPluginMenuPageService +import com.ritense.externalplugin.web.rest.dto.ExternalPluginMenuPageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Lists activated external-plugin `page` bundles for the menu-configuration builder. A non-management + * `/api/v1/...` path gated `.authenticated()` (every admin building the menu can read it); access to + * the actual page data is enforced at render time by PBAC ∩ the configuration's allowlist via the + * downscoped user token, so this list is intentionally unfiltered. + */ +@RestController +@SkipComponentScan +@RequestMapping("/api/v1/external-plugin", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginMenuPageResource( + private val menuPageService: ExternalPluginMenuPageService, +) { + + @EndpointDescription( + en = "List external plugin menu pages", + nl = "Externe-pluginmenupagina's ophalen", + ) + @GetMapping("/menu-pages") + fun getMenuPages(): ResponseEntity> { + return ResponseEntity.ok(menuPageService.getMenuPages()) + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResource.kt new file mode 100644 index 0000000000..a3ed114e1b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResource.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.externalplugin.security.ExternalPluginUserPrincipal +import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import io.jsonwebtoken.Jwts +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.UUID + +/** + * Introspection endpoint for external-plugin **user** tokens. The plugin host calls it before + * executing Wasm for its public `/data` route: the host cannot validate the HS256 token locally + * (the signing key never leaves GZAC), so it presents the token here (`Authorization: Bearer + * `) and learns whether GZAC accepts it — and for **which** configuration. + * + * The caller authenticates *with the token under introspection*: [ExternalPluginUserTokenFilter] + * has already verified the signature, type claim and expiry before this resource runs. The + * resource therefore only echoes the token's own claims back (subject, configuration id, expiry) + * — it reads nothing else, so a caller learns nothing it did not already hold. Any other + * authenticated principal (an interactive Keycloak user, a plugin service token) is rejected: + * introspection is only meaningful for user tokens. + */ +@Controller +@SkipComponentScan +@RequestMapping("/api/v1/external-plugin", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginUserTokenIntrospectionResource( + keyProvider: ExternalPluginUserTokenKeyProvider, +) { + + private val parser = Jwts.parser().verifyWith(keyProvider.signingKey).build() + + @EndpointDescription( + en = "Introspect the presented external plugin user token", + nl = "Het aangeboden externe-plugin gebruikerstoken introspecteren", + ) + @GetMapping("/user-token/introspect") + fun introspect(): ResponseEntity { + val authentication = SecurityContextHolder.getContext().authentication + val principal = authentication?.principal as? ExternalPluginUserPrincipal + ?: throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Introspection is only available for external plugin user tokens", + ) + + // The recognising filter stores the raw JWT as the authentication's credentials; the expiry + // is not part of the principal, so it is read from the (already-verified) token itself. + val token = authentication.credentials as? String + ?: throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Introspection is only available for external plugin user tokens", + ) + val expiresAt = parser.parseSignedClaims(token).payload.expiration.toInstant() + + return ResponseEntity.ok( + IntrospectionResponse( + subject = principal.userLogin, + configurationId = principal.pluginConfigId, + expiresAt = expiresAt, + ) + ) + } + + data class IntrospectionResponse( + val subject: String, + val configurationId: UUID, + val expiresAt: Instant, + ) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResource.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResource.kt new file mode 100644 index 0000000000..a3581a70c9 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResource.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.authorization.annotation.RunWithoutAuthorization +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.service.ExternalPluginUserTokenService +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import com.ritense.valtimo.contract.utils.SecurityUtils +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.UUID + +/** + * Mints a short-lived, downscoped user token for an external plugin's iframe (via the Angular + * parent-proxy). Deliberately a **non-management** `/api/v1/...` path so it is *not* ADMIN-gated: + * any authenticated user may mint one, because the result is always bounded by PBAC ∩ the plugin + * configuration's granted-endpoint allowlist (system-plan §6.6.1). The minted token never grants + * more than the requesting user already has. + */ +@Controller +@SkipComponentScan +@RequestMapping("/api/v1/external-plugin", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ExternalPluginUserTokenResource( + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val grantedEndpointRepository: ExternalPluginGrantedEndpointRepository, + private val userTokenService: ExternalPluginUserTokenService, +) { + + /** + * `@RunWithoutAuthorization` covers the plain configuration-existence read (no PBAC entity). It + * does **not** affect the minted token: the user's login + roles are read from the still-intact + * `SecurityContext`, so the result is always bounded by PBAC ∩ allowlist. + */ + @RunWithoutAuthorization + @EndpointDescription( + en = "Mint a downscoped user token for an external plugin configuration", + nl = "Versmald gebruikerstoken voor een externe-pluginconfiguratie aanmaken", + ) + @PostMapping("/configuration/{configurationId}/user-token") + fun mintUserToken( + @PathVariable configurationId: UUID, + ): ResponseEntity { + val userLogin = SecurityUtils.getCurrentUserLogin() + ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authenticated user") + val roles = SecurityUtils.getCurrentUserRoles() + + val configuration = configurationRepository.findById(configurationId).orElseThrow { + ResponseStatusException( + HttpStatus.NOT_FOUND, + "External plugin configuration $configurationId not found", + ) + } + + // A definition whose package content changed after acceptance no longer gets tokens of any + // kind until an admin re-accepts it — the iframe surface goes dark alongside the host push. + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) + if (definition?.requiresReacceptance == true) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Plugin '${definition.pluginId}@${definition.version}' changed on its host and " + + "awaits re-acceptance by an administrator", + ) + } + + val issued = userTokenService.issue(userLogin, roles, configurationId, configuration.tokenGeneration) + // The configuration's granted endpoints ride along so the iframe host can precheck proxied + // calls client-side (audit-C1). This leaks nothing: the caller just received a token scoped + // to exactly these endpoints — the server-side allowlist remains authoritative. + val grantedEndpoints = grantedEndpointRepository.findAllByConfigurationId(configurationId) + .map { GrantedEndpointDto(it.httpMethod, it.endpointPattern) } + return ResponseEntity.ok(UserTokenResponse(issued.token, issued.expiresAt, grantedEndpoints)) + } + + data class UserTokenResponse( + val userToken: String, + val expiresAt: Instant, + val grantedEndpoints: List, + ) + + data class GrantedEndpointDto( + val method: String, + val pattern: String, + ) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ConfigurationDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ConfigurationDto.kt new file mode 100644 index 0000000000..9fa1154700 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ConfigurationDto.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest.dto + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginGrantedCapability +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import com.ritense.externalplugin.domain.ExternalPluginGrantedEvent +import java.time.Instant +import java.util.UUID + +data class GrantedEndpointEntry( + val method: String, + val pattern: String, +) + +data class GrantedEndpointResponse( + val id: UUID, + val configurationId: UUID, + val httpMethod: String, + val endpointPattern: String, + val grantedAt: Instant, +) { + companion object { + fun from(entity: ExternalPluginGrantedEndpoint) = GrantedEndpointResponse( + id = entity.id, + configurationId = entity.configurationId, + httpMethod = entity.httpMethod, + endpointPattern = entity.endpointPattern, + grantedAt = entity.grantedAt, + ) + } +} + +data class GrantedEventEntry( + val eventType: String, +) + +data class GrantedEventResponse( + val id: UUID, + val configurationId: UUID, + val eventType: String, + val grantedAt: Instant, +) { + companion object { + fun from(entity: ExternalPluginGrantedEvent) = GrantedEventResponse( + id = entity.id, + configurationId = entity.configurationId, + eventType = entity.eventType, + grantedAt = entity.grantedAt, + ) + } +} + +data class GrantedCapabilityResponse( + val id: UUID, + val configurationId: UUID, + val capability: String, + val grantedAt: Instant, +) { + companion object { + fun from(entity: ExternalPluginGrantedCapability) = GrantedCapabilityResponse( + id = entity.id, + configurationId = entity.configurationId, + capability = entity.capability.value, + grantedAt = entity.grantedAt, + ) + } +} + +data class ConfigurationCreateRequest( + val definitionId: UUID, + val title: String, + val properties: ObjectNode, + val grantedEndpoints: List, + val grantedEvents: List = emptyList(), + val grantedCapabilities: List = emptyList(), +) + +data class ConfigurationUpdateRequest( + val title: String, + val properties: ObjectNode, + val grantedEndpoints: List? = null, +) + +data class ConfigurationResponse( + val id: UUID, + val definitionId: UUID, + val title: String, + val createdAt: Instant, + /** Current revocation counter — bumped by `POST /configuration/{id}/revoke-tokens`. */ + val tokenGeneration: Long, +) { + companion object { + fun from(configuration: ExternalPluginConfiguration) = ConfigurationResponse( + id = configuration.id, + definitionId = configuration.definitionId, + title = configuration.title, + createdAt = configuration.createdAt, + tokenGeneration = configuration.tokenGeneration, + ) + } +} + +data class ConfigurationDetailResponse( + val id: UUID, + val definitionId: UUID, + val title: String, + val properties: ObjectNode, + val grantedEndpoints: List, + val grantedEvents: List, + val grantedCapabilities: List, + val createdAt: Instant, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/DefinitionDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/DefinitionDto.kt new file mode 100644 index 0000000000..4dc413ad7b --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/DefinitionDto.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest.dto + +import com.fasterxml.jackson.databind.JsonNode +import com.ritense.externalplugin.compatibility.CompatibilityResult +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import java.util.UUID + +/** Echoes the pending content hash the admin reviewed — see `acceptDefinitionContent`. */ +data class AcceptContentRequest( + val contentHash: String, +) + +data class DefinitionResponse( + val id: UUID, + val pluginId: String, + val version: String, + val name: String?, + val description: String?, + val provider: String?, + val hostId: UUID, + val baseUrl: String, + val status: ExternalPluginDefinitionStatus, + val configurationSchema: JsonNode?, + val manifest: JsonNode?, + /** + * Declared compatibility bounds (from the manifest's `compatibility` block) and the resolved + * outcome of comparing them against the running GZAC version. [compatible] is `true` when the + * plugin targets this version (or when it could not be judged); when `false` the management UI + * surfaces a non-blocking warning. [currentGzacVersion] is the running version the check used, + * or null when it could not be determined. + */ + val minGzacVersion: String?, + val maxGzacVersion: String?, + val currentGzacVersion: String?, + val compatible: Boolean, + /** + * Absolute URL the frontend can fetch the logo from, or null when the plugin shipped no logo. + * The host serves the file at `GET /plugins/:id/:version/logo`; this URL composes the host + * `baseUrl` with the version so the management UI can use it directly in ``. + */ + val logoUrl: String?, + /** + * The package content hash pinned at discovery, the hash the host serves *now* when it + * differs, and whether an admin must re-accept before the plugin runs again. Re-acceptance is + * a deliberately API-only recovery act (no management-UI flow): the caller passes + * [pendingContentHash] back on `POST /definition/{id}/accept-content` to confirm which package + * it reviewed. + */ + val contentHash: String?, + val pendingContentHash: String?, + val requiresReacceptance: Boolean, +) { + companion object { + fun from(definition: ExternalPluginDefinition, compatibility: CompatibilityResult): DefinitionResponse { + val hasLogo = definition.manifestJson?.get("logo")?.isTextual == true + return DefinitionResponse( + id = definition.id, + pluginId = definition.pluginId, + version = definition.version, + name = definition.name, + description = definition.description, + provider = definition.provider, + hostId = definition.hostId, + baseUrl = definition.baseUrl, + status = definition.status, + configurationSchema = definition.configSchema, + manifest = definition.manifestJson, + minGzacVersion = definition.minGzacVersion, + maxGzacVersion = definition.maxGzacVersion, + currentGzacVersion = compatibility.currentGzacVersion, + compatible = compatibility.compatible, + logoUrl = if (hasLogo) "${definition.baseUrl}/${definition.version}/logo" else null, + contentHash = definition.contentHash, + pendingContentHash = definition.pendingContentHash, + requiresReacceptance = definition.requiresReacceptance, + ) + } + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ExternalPluginMenuPageDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ExternalPluginMenuPageDto.kt new file mode 100644 index 0000000000..7476006c1e --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/ExternalPluginMenuPageDto.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest.dto + +import java.util.UUID + +/** + * One activated external-plugin `page` bundle that an admin can place in the menu. There is no role + * field: access is enforced by PBAC at render time (the page route mints a downscoped user token). + * [titleTranslations] holds the per-locale title resolved from the manifest's `translations` block + * for [title] (which may itself be a translation key or a literal); the frontend localizes from it, + * falling back to [title] and then [configurationTitle]. + */ +data class ExternalPluginMenuPageDto( + val configurationId: UUID, + val configurationTitle: String, + val bundleKey: String?, + val bundleUrl: String?, + val title: String?, + val titleTranslations: Map, + val icon: String?, +) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/HostDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/HostDto.kt new file mode 100644 index 0000000000..a26589343f --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/web/rest/dto/HostDto.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest.dto + +import com.ritense.externalplugin.domain.EventQueueMode +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostKind +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import java.time.Instant +import java.util.UUID + +data class HostCreateRequest( + val name: String, + val baseUrl: String, + val secret: String, + val gzacCallbackBaseUrl: String, + val eventBrokerAmqpUrl: String?, + val eventBrokerExchange: String?, + val eventQueueMode: EventQueueMode = EventQueueMode.LIVE, + val eventQueueTtlMs: Long? = null, + val kind: ExternalPluginHostKind = ExternalPluginHostKind.PLUGIN_HOST, +) + +data class HostResponse( + val id: UUID, + val name: String, + val baseUrl: String, + val kind: ExternalPluginHostKind, + val status: ExternalPluginHostStatus, + val lastHealthCheck: Instant?, + val gzacCallbackBaseUrl: String?, + /** Redacted — the userinfo is replaced with `***`; broker credentials never leave the server. */ + val eventBrokerAmqpUrl: String?, + val eventBrokerExchange: String?, + val eventQueueMode: EventQueueMode, + val eventQueueTtlMs: Long?, +) { + companion object { + /** Marker replacing the `user:password` userinfo of an AMQP URL in API responses. */ + const val AMQP_USERINFO_REDACTION = "***" + + fun from(host: ExternalPluginHost) = HostResponse( + id = host.id, + name = host.name, + baseUrl = host.baseUrl, + kind = host.kind, + status = host.status, + lastHealthCheck = host.lastHealthCheck, + gzacCallbackBaseUrl = host.gzacCallbackBaseUrl, + eventBrokerAmqpUrl = redactAmqpUserInfo(host.eventBrokerAmqpUrl), + eventBrokerExchange = host.eventBrokerExchange, + eventQueueMode = host.eventQueueMode, + eventQueueTtlMs = host.eventQueueTtlMs, + ) + + /** + * Replaces the userinfo (`user:password@`) of an AMQP(S) URL with `***@` so credentials are + * never echoed to the browser. URLs without userinfo pass through unchanged; the full URL + * stays server-side on the host row. + */ + fun redactAmqpUserInfo(url: String?): String? { + if (url.isNullOrBlank()) return url + return url.replace(Regex("^(amqps?://)[^@/]+@"), "$1$AMQP_USERINFO_REDACTION@") + } + } +} + +/** + * Suggested defaults for the add-host form. Surfaced via `GET /host-defaults`. + * + * - `gzacCallbackBaseUrl`: URL the admin reached GZAC at, derived from the current request. + * - `eventBrokerAmqpUrl`: built from `spring.rabbitmq.*` — GZAC's own broker view. + * - `eventBrokerExchange`: the exchange GZAC publishes to (from `valtimo.outbox.publisher.rabbitmq.exchange`). + * - `defaultEventQueueTtlMs` / `minEventQueueTtlMs` / `maxEventQueueTtlMs`: the queue inactivity + * TTL bounds the backend will accept when a host opts into DURABLE mode. Pre-fills and validates + * the TTL input in the add-host UI. + */ +data class HostDefaultsResponse( + val gzacCallbackBaseUrl: String, + val eventBrokerAmqpUrl: String, + val eventBrokerExchange: String, + val defaultEventQueueTtlMs: Long, + val minEventQueueTtlMs: Long, + val maxEventQueueTtlMs: Long, +) + +/** + * Narrow update payload: flips the per-host event-queue mode and adjusts the TTL on an existing + * host without touching any other field. baseUrl/secret/broker remain immutable because the + * security check that pins broker credentials to a confidential baseUrl runs at registration time. + */ +data class HostEventQueueUpdateRequest( + val eventQueueMode: EventQueueMode, + val eventQueueTtlMs: Long?, +) diff --git a/backend/external-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/external-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..11938380cc --- /dev/null +++ b/backend/external-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# Copyright 2015-2026 Ritense BV, the Netherlands. +# +# Licensed under EUPL, Version 1.2 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +com.ritense.externalplugin.autoconfigure.ExternalPluginAutoConfiguration diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt new file mode 100644 index 0000000000..fb32e04528 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin + +import com.ritense.valtimo.contract.authentication.UserManagementService +import com.ritense.valtimo.contract.mail.MailSender +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringExtension + +@SpringBootTest +@ExtendWith(SpringExtension::class) +@Tag("integration") +class BaseIntegrationTest { + + @MockitoBean + lateinit var userManagementService: UserManagementService + + @MockitoBean + lateinit var mailSender: MailSender + +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt new file mode 100644 index 0000000000..1401283215 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class TestApplication { + + fun main(args: Array) { + runApplication(*args) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt new file mode 100644 index 0000000000..18106bf1d1 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.autoconfigure + +import com.ritense.externalplugin.BaseIntegrationTest +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import com.ritense.valtimo.processlink.listener.ProcessLinkChangedEventListener +import com.ritense.valtimo.processlink.service.PluginConfigurationMappingResolverImpl +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext + +/** + * Boots a Spring context combining `plugin-valtimo` and `external-plugin` on the same classpath — + * every real application does, since `valtimo-dependencies` pulls in both as `api` deps. Regresses + * two historical bean-registration bugs found in this codebase: + * + * - `ProcessLinkAutoConfiguration.processLinkChangedEventListener` used to take a single-value + * [PluginConfigurationMappingResolver] constructor parameter, which fails context refresh with a + * `NoUniqueBeanDefinitionException` once both [PluginConfigurationMappingResolverImpl] and + * [ExternalPluginConfigurationMappingResolver] are registered beans of that interface. + * - `ProcessLinkAutoConfiguration.pluginConfigurationMappingResolver` used to carry + * `@ConditionalOnMissingBean(PluginConfigurationMappingResolver::class)` (the shared interface) + * instead of its own concrete class, which silently suppresses whichever of the two resolver + * beans loses the `@Configuration` class processing order race — no boot failure, just one + * resolver family quietly never running. + */ +class DualPluginConfigurationMappingResolverIntTest @Autowired constructor( + private val applicationContext: ApplicationContext, +) : BaseIntegrationTest() { + + @Test + fun `context contains exactly two PluginConfigurationMappingResolver beans`() { + val resolvers = applicationContext.getBeansOfType(PluginConfigurationMappingResolver::class.java) + + assertThat(resolvers).hasSize(2) + assertThat(resolvers.values).hasAtLeastOneElementOfType(PluginConfigurationMappingResolverImpl::class.java) + assertThat(resolvers.values).hasAtLeastOneElementOfType(ExternalPluginConfigurationMappingResolver::class.java) + } + + @Test + fun `ProcessLinkChangedEventListener bean is present with both resolvers injected`() { + val listeners = applicationContext.getBeansOfType(ProcessLinkChangedEventListener::class.java) + + assertThat(listeners).hasSize(1) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt new file mode 100644 index 0000000000..fe141d2777 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.autoconfigure + +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean + +/** + * The embedded plugin system registers its own `PluginConfigurationMappingResolver` bean next to + * this module's, and every real application has both modules on the classpath. An interface-typed + * `@ConditionalOnMissingBean` would make whichever module's configuration is processed first + * silently suppress the other resolver, so the condition must target this module's concrete class. + * No single-module context test exercises that cross-module combination — pinned by reflection. + */ +class ExternalPluginAutoConfigurationWiringTest { + + @Test + fun `resolver bean condition targets its own concrete class, not the shared interface`() { + val beanMethod = ExternalPluginAutoConfiguration::class.java.declaredMethods + .single { it.name == "externalPluginConfigurationMappingResolver" } + + val condition = beanMethod.getAnnotation(ConditionalOnMissingBean::class.java) + + assertThat(condition).isNotNull + assertThat(condition.value.map { it.java }) + .containsExactly(ExternalPluginConfigurationMappingResolver::class.java) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClientHmacTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClientHmacTest.kt new file mode 100644 index 0000000000..613499928d --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/client/ExternalPluginHostClientHmacTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.client + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.EventQueueMode +import com.ritense.externalplugin.security.ExternalPluginHmacSigner +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.mock.http.client.MockClientHttpRequest +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.web.client.RestTemplate +import java.security.MessageDigest +import java.util.HexFormat +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Proves every GZAC→host route the client calls is authenticated with the replay-windowed, + * body-bound HMAC scheme rather than a static `Authorization: Bearer` token. The expected signature + * is recomputed here with a plain JDK HMAC (an oracle independent of the production signer) over the + * canonical `{method}\n{path}\n{timestamp}\n{bodyHash}` string. + */ +class ExternalPluginHostClientHmacTest { + + private val secret = "host-admin-secret" + private val baseUrl = "http://plugin-host:8090" + private val objectMapper = ObjectMapper() + private lateinit var restTemplate: RestTemplate + private lateinit var server: MockRestServiceServer + private lateinit var client: ExternalPluginHostClient + + @BeforeEach + fun setup() { + restTemplate = RestTemplate() + server = MockRestServiceServer.createServer(restTemplate) + client = ExternalPluginHostClient(restTemplate, objectMapper) + } + + @Test + fun `pushConfiguration signs the request body and sends no bearer token`() { + val configId = UUID.randomUUID().toString() + val serviceToken = "eyJ-fresh-service-token" + val path = "/api/host/configurations/$configId" + + server.expect(requestTo("$baseUrl$path")) + .andExpect(method(HttpMethod.POST)) + .andExpect { request -> + request as MockClientHttpRequest + // The freshly issued service token must travel inside the signed body, so it cannot + // be swapped or replayed without breaking the signature. + val bodyString = String(request.bodyAsBytes) + assertThat(bodyString).contains(serviceToken) + // The host learns the queue mode + TTL from this push and uses them to declare its + // own queue; both must travel inside the signed body so they cannot be swapped. + assertThat(bodyString).contains("\"queueMode\":\"durable\"") + assertThat(bodyString).contains("\"queueTtlMs\":259200000") + assertSigned(request, "POST", path, request.bodyAsBytes) + } + .andRespond( + withStatus(HttpStatus.CREATED) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"configurationId":"$configId"}""") + ) + + val pushed = client.pushConfiguration( + baseUrl = baseUrl, + adminToken = secret, + configId = configId, + pluginId = "case-summary", + pluginVersion = "0.1.0", + properties = objectMapper.createObjectNode(), + serviceToken = serviceToken, + gzacBaseUrl = "http://gzac:8080", + eventSubscriptions = listOf("com.ritense.valtimo.document.created"), + eventBrokerUrl = "amqp://guest:guest@broker:5672", + eventBrokerExchange = "valtimo-events", + eventBrokerExchangeType = "fanout", + eventQueueMode = EventQueueMode.DURABLE, + eventQueueTtlMs = 259_200_000L, + ) + + assertThat(pushed).isTrue() + server.verify() + } + + @Test + fun `pushConfiguration omits queueTtlMs from the body when null`() { + val configId = UUID.randomUUID().toString() + val path = "/api/host/configurations/$configId" + + server.expect(requestTo("$baseUrl$path")) + .andExpect(method(HttpMethod.POST)) + .andExpect { request -> + request as MockClientHttpRequest + val bodyString = String(request.bodyAsBytes) + assertThat(bodyString).contains("\"queueMode\":\"live\"") + assertThat(bodyString).doesNotContain("queueTtlMs") + } + .andRespond( + withStatus(HttpStatus.CREATED) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"configurationId":"$configId"}""") + ) + + client.pushConfiguration( + baseUrl = baseUrl, + adminToken = secret, + configId = configId, + pluginId = "case-summary", + pluginVersion = "0.1.0", + properties = objectMapper.createObjectNode(), + serviceToken = "service-token", + gzacBaseUrl = "http://gzac:8080", + eventSubscriptions = emptyList(), + eventBrokerUrl = "amqp://guest:guest@broker:5672", + eventBrokerExchange = "valtimo-events", + eventBrokerExchangeType = "fanout", + eventQueueMode = EventQueueMode.LIVE, + eventQueueTtlMs = null, + ) + + server.verify() + } + + @Test + fun `deleteConfiguration signs an empty body and sends no bearer token`() { + val configId = UUID.randomUUID().toString() + val path = "/api/host/configurations/$configId" + + server.expect(requestTo("$baseUrl$path")) + .andExpect(method(HttpMethod.DELETE)) + .andExpect { request -> + request as MockClientHttpRequest + assertSigned(request, "DELETE", path, ByteArray(0)) + } + .andRespond(withStatus(HttpStatus.NO_CONTENT)) + + val deleted = client.deleteConfiguration(baseUrl, secret, configId) + + assertThat(deleted).isTrue() + server.verify() + } + + @Test + fun `listPlugins signs an empty body and sends no bearer token`() { + val path = "/api/host/plugins" + + server.expect(requestTo("$baseUrl$path")) + .andExpect(method(HttpMethod.GET)) + .andExpect { request -> + request as MockClientHttpRequest + assertSigned(request, "GET", path, ByteArray(0)) + } + .andRespond( + withStatus(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON).body("[]") + ) + + client.listPlugins(baseUrl, secret) + + server.verify() + } + + @Test + fun `uploadPlugin signs the uploaded file bytes rather than the multipart envelope`() { + val path = "/api/host/plugins" + val fileBytes = "PK-fake-zip-content".toByteArray() + + server.expect(requestTo("$baseUrl$path")) + .andExpect(method(HttpMethod.POST)) + .andExpect { request -> + request as MockClientHttpRequest + // The signed body is the raw file, not the multipart envelope the wire carries. + assertThat(request.bodyAsBytes).isNotEqualTo(fileBytes) + assertSigned(request, "POST", path, fileBytes) + } + .andRespond( + withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body("{}") + ) + + client.uploadPlugin(baseUrl, secret, "plugin.zip", fileBytes) + + server.verify() + } + + private fun assertSigned( + request: MockClientHttpRequest, + method: String, + path: String, + signedBody: ByteArray, + ) { + assertThat(request.headers.getFirst(HttpHeaders.AUTHORIZATION)).isNull() + val timestamp = request.headers.getFirst(ExternalPluginHmacSigner.TIMESTAMP_HEADER) + val signature = request.headers.getFirst(ExternalPluginHmacSigner.SIGNATURE_HEADER) + assertThat(timestamp).isNotNull() + assertThat(signature).isEqualTo(expectedSignature(method, path, timestamp!!, signedBody)) + } + + private fun expectedSignature( + method: String, + path: String, + timestamp: String, + body: ByteArray, + ): String { + val bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)) + val payload = "$method\n$path\n$timestamp\n$bodyHash" + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) + return HexFormat.of().formatHex(mac.doFinal(payload.toByteArray(Charsets.UTF_8))) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProviderTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProviderTest.kt new file mode 100644 index 0000000000..5596eb386f --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/DefaultGzacVersionProviderTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DefaultGzacVersionProviderTest { + + @Test + fun `prefers the configured override over the library version`() { + val provider = DefaultGzacVersionProvider(versionOverride = "9.9.9", libraryVersion = "13.5.0") + + assertThat(provider.getCurrentVersion()).isEqualTo("9.9.9") + } + + @Test + fun `falls back to the valtimo library version when no override is configured`() { + val provider = DefaultGzacVersionProvider(versionOverride = "", libraryVersion = "13.5.0") + + assertThat(provider.getCurrentVersion()).isEqualTo("13.5.0") + } + + @Test + fun `ignores a blank override and a blank library version`() { + val provider = DefaultGzacVersionProvider(versionOverride = " ", libraryVersion = " ") + + assertThat(provider.getCurrentVersion()).isNull() + } + + @Test + fun `returns null when nothing resolves a version`() { + val provider = DefaultGzacVersionProvider(versionOverride = null, libraryVersion = null) + + assertThat(provider.getCurrentVersion()).isNull() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityCheckerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityCheckerTest.kt new file mode 100644 index 0000000000..a085281cd2 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/GzacCompatibilityCheckerTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class GzacCompatibilityCheckerTest { + + private fun checker(currentVersion: String?) = + GzacCompatibilityChecker(GzacVersionProvider { currentVersion }) + + @Test + fun `is compatible when current version is above the minimum`() { + val result = checker("13.1.3").check(minGzacVersion = "12.0.0", maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + assertThat(result.status).isEqualTo(CompatibilityStatus.COMPATIBLE) + assertThat(result.currentGzacVersion).isEqualTo("13.1.3") + } + + @Test + fun `is incompatible when current version is below the minimum`() { + val result = checker("13.1.3").check(minGzacVersion = "14.0.0", maxGzacVersion = null) + + assertThat(result.compatible).isFalse() + assertThat(result.status).isEqualTo(CompatibilityStatus.BELOW_MINIMUM) + assertThat(result.minGzacVersion).isEqualTo("14.0.0") + } + + @Test + fun `is incompatible when current version is above the maximum`() { + val result = checker("13.1.3").check(minGzacVersion = null, maxGzacVersion = "13.0.0") + + assertThat(result.compatible).isFalse() + assertThat(result.status).isEqualTo(CompatibilityStatus.ABOVE_MAXIMUM) + assertThat(result.maxGzacVersion).isEqualTo("13.0.0") + } + + @Test + fun `is compatible when current version is within an inclusive range`() { + val result = checker("13.1.3").check(minGzacVersion = "12.0.0", maxGzacVersion = "14.0.0") + + assertThat(result.compatible).isTrue() + assertThat(result.status).isEqualTo(CompatibilityStatus.COMPATIBLE) + } + + @Test + fun `treats the minimum bound as inclusive`() { + val result = checker("13.1.3").check(minGzacVersion = "13.1.3", maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + } + + @Test + fun `treats the maximum bound as inclusive`() { + val result = checker("13.1.3").check(minGzacVersion = null, maxGzacVersion = "13.1.3") + + assertThat(result.compatible).isTrue() + } + + @Test + fun `is compatible when no bounds are declared`() { + val result = checker("13.1.3").check(minGzacVersion = null, maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + assertThat(result.status).isEqualTo(CompatibilityStatus.COMPATIBLE) + } + + @Test + fun `does not warn when the current version cannot be determined`() { + val result = checker(null).check(minGzacVersion = "14.0.0", maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + assertThat(result.status).isEqualTo(CompatibilityStatus.CURRENT_VERSION_UNKNOWN) + assertThat(result.currentGzacVersion).isNull() + } + + @Test + fun `does not warn when the current version is not valid semver`() { + val result = checker("not-a-version").check(minGzacVersion = "14.0.0", maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + assertThat(result.status).isEqualTo(CompatibilityStatus.CURRENT_VERSION_UNKNOWN) + } + + @Test + fun `ignores an unparseable bound rather than blocking`() { + val result = checker("13.1.3").check(minGzacVersion = "not-a-version", maxGzacVersion = null) + + assertThat(result.compatible).isTrue() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspectorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspectorTest.kt new file mode 100644 index 0000000000..5826f1c161 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/compatibility/PluginPackageInspectorTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.compatibility + +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class PluginPackageInspectorTest { + + private val inspector = PluginPackageInspector(ObjectMapper()) + + @Test + fun `reads both compatibility bounds from the root manifest`() { + val zip = zip("manifest.json" to MANIFEST_BOTH_BOUNDS) + + val range = inspector.readCompatibilityRange(zip) + + assertThat(range).isNotNull + assertThat(range!!.minGzacVersion).isEqualTo("12.0.0") + assertThat(range.maxGzacVersion).isEqualTo("13.5.0") + } + + @Test + fun `reads a single bound`() { + val zip = zip("manifest.json" to """{"pluginId":"x","version":"1.0.0","compatibility":{"minGzacVersion":"14.0.0"}}""") + + val range = inspector.readCompatibilityRange(zip) + + assertThat(range!!.minGzacVersion).isEqualTo("14.0.0") + assertThat(range.maxGzacVersion).isNull() + } + + @Test + fun `returns null when there is no compatibility block`() { + val zip = zip("manifest.json" to """{"pluginId":"x","version":"1.0.0"}""") + + assertThat(inspector.readCompatibilityRange(zip)).isNull() + } + + @Test + fun `returns null when the compatibility block has no bounds`() { + val zip = zip("manifest.json" to """{"pluginId":"x","version":"1.0.0","compatibility":{}}""") + + assertThat(inspector.readCompatibilityRange(zip)).isNull() + } + + @Test + fun `returns null when the package has no manifest`() { + val zip = zip("plugin.wasm" to "binary") + + assertThat(inspector.readCompatibilityRange(zip)).isNull() + } + + @Test + fun `returns null for a non-zip payload`() { + assertThat(inspector.readCompatibilityRange("not a zip".toByteArray())).isNull() + } + + @Test + fun `prefers the root manifest over a nested one`() { + val zip = zip( + "frontend/manifest.json" to """{"compatibility":{"minGzacVersion":"99.0.0"}}""", + "manifest.json" to MANIFEST_BOTH_BOUNDS, + ) + + val range = inspector.readCompatibilityRange(zip) + + assertThat(range!!.minGzacVersion).isEqualTo("12.0.0") + } + + private fun zip(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zos -> + entries.forEach { (name, content) -> + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray()) + zos.closeEntry() + } + } + return out.toByteArray() + } + + private companion object { + private const val MANIFEST_BOTH_BOUNDS = + """{"pluginId":"x","version":"1.0.0","compatibility":{"minGzacVersion":"12.0.0","maxGzacVersion":"13.5.0"}}""" + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapabilityTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapabilityTest.kt new file mode 100644 index 0000000000..8d428c2213 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/domain/ExternalPluginCapabilityTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.domain + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class ExternalPluginCapabilityTest { + + @Test + fun `parses every known capability value`() { + assertThat(ExternalPluginCapability.fromValue("gzac_api")).isEqualTo(ExternalPluginCapability.GZAC_API) + assertThat(ExternalPluginCapability.fromValue("http_request")).isEqualTo(ExternalPluginCapability.HTTP_REQUEST) + assertThat(ExternalPluginCapability.fromValue("kv")).isEqualTo(ExternalPluginCapability.KV) + assertThat(ExternalPluginCapability.fromValue("log")).isEqualTo(ExternalPluginCapability.LOG) + } + + @Test + fun `rejects unknown capability value with the known values in the message`() { + assertThatThrownBy { ExternalPluginCapability.fromValue("filesystem") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Unknown capability 'filesystem'") + .hasMessageContaining("gzac_api, http_request, kv, log") + } + + @Test + fun `rejects enum name spelling - only the wire value is accepted`() { + assertThatThrownBy { ExternalPluginCapability.fromValue("HTTP_REQUEST") } + .isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `converter round-trips every capability through its column value`() { + val converter = ExternalPluginCapabilityConverter() + ExternalPluginCapability.entries.forEach { capability -> + val column = converter.convertToDatabaseColumn(capability) + assertThat(column).isEqualTo(capability.value) + assertThat(converter.convertToEntityAttribute(column)).isEqualTo(capability) + } + } + + @Test + fun `converter rejects unknown column value`() { + assertThatThrownBy { ExternalPluginCapabilityConverter().convertToEntityAttribute("random stuff") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Unknown capability 'random stuff'") + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/endpoint/EndpointDescriptionCoverageTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/endpoint/EndpointDescriptionCoverageTest.kt new file mode 100644 index 0000000000..7869ee7a6f --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/endpoint/EndpointDescriptionCoverageTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.endpoint + +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import org.junit.jupiter.api.Test +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.core.type.classreading.CachingMetadataReaderFactory +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Enforces that every REST endpoint exposed by a controller on the classpath documents itself with + * an [EndpointDescription] carrying both an English and a Dutch text. The descriptions are the + * single source of truth shown to an admin when granting an external plugin access to endpoints. + * + * This covers **all** endpoints, not just management ones, so the requirement cannot silently drift + * as new controllers are added. It uses bytecode/reflection scanning and does NOT require a Spring + * application context. The set of controllers it sees is determined by the modules on the + * external-plugin test classpath (see this module's build.gradle). + */ +class EndpointDescriptionCoverageTest { + + @Test + fun `every controller endpoint must declare an EndpointDescription with English and Dutch text`() { + val endpoints = findEndpointMethods() + assertTrue( + endpoints.isNotEmpty(), + "No controller endpoints found on the classpath — the scan is probably misconfigured" + ) + + val failures = mutableListOf() + for ((controller, method) in endpoints) { + val annotation = method.getAnnotation(EndpointDescription::class.java) + when { + annotation == null -> + failures.add("$controller#${method.name} is missing @EndpointDescription") + annotation.en.isBlank() -> + failures.add("$controller#${method.name} has a blank English (en) description") + annotation.nl.isBlank() -> + failures.add("$controller#${method.name} has a blank Dutch (nl) description") + } + } + + if (failures.isNotEmpty()) { + fail( + "Found ${failures.size} endpoint(s) without a complete @EndpointDescription " + + "(both 'en' and 'nl' are required):\n" + + failures.sorted().joinToString("\n") { " - $it" } + ) + } + } + + /** + * Scans the classpath for all controller classes under `com.ritense` and returns every handler + * method (a method carrying a Spring request-mapping annotation) paired with its controller's + * simple name. + */ + private fun findEndpointMethods(): List> { + val resolver = PathMatchingResourcePatternResolver() + val readerFactory = CachingMetadataReaderFactory(resolver) + val resources = resolver.getResources("classpath*:com/ritense/**/*.class") + + resolver.getResources("classpath*:com/valtimo/**/*.class") + val classLoader = Thread.currentThread().contextClassLoader + + val endpoints = mutableListOf>() + for (resource in resources) { + val clazz = try { + val className = readerFactory.getMetadataReader(resource).classMetadata.className + Class.forName(className, false, classLoader) + } catch (_: Throwable) { + continue + } + + if (clazz.isInterface || Modifier.isAbstract(clazz.modifiers)) continue + if (!isController(clazz)) continue + + for (method in clazz.declaredMethods) { + if (isMapped(method)) { + endpoints.add((clazz.simpleName ?: clazz.name) to method) + } + } + } + return endpoints + } + + private fun isController(clazz: Class<*>): Boolean = + clazz.annotations.any { + val name = it.annotationClass.qualifiedName + name == "org.springframework.stereotype.Controller" || + name == "org.springframework.web.bind.annotation.RestController" + } + + private fun isMapped(method: Method): Boolean = + method.isAnnotationPresent(GetMapping::class.java) || + method.isAnnotationPresent(PostMapping::class.java) || + method.isAnnotationPresent(PutMapping::class.java) || + method.isAnnotationPresent(DeleteMapping::class.java) || + method.isAnnotationPresent(PatchMapping::class.java) || + method.isAnnotationPresent(RequestMapping::class.java) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseExceptionTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseExceptionTest.kt new file mode 100644 index 0000000000..0e6b6bb4ef --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/exception/ExternalPluginHostInUseExceptionTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.exception + +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.zalando.problem.Status +import java.util.UUID + +/** + * The `parameters` map on `AbstractThrowableProblem` is rendered as top-level keys on the + * `application/problem+json` body by Zalando's `ProblemModule`, alongside `title`, `status`, and + * `detail`. This test pins the shape of those parameters so the frontend contract cannot drift + * silently. + */ +class ExternalPluginHostInUseExceptionTest { + + @Test + fun `carries hostId and usages alongside conflict status and human-readable title`() { + val hostId = UUID.randomUUID() + val configurationId = UUID.randomUUID() + val processLinkId = UUID.randomUUID() + val usage = PluginUsageDto( + configurationId = configurationId, + configurationTitle = "Primary CRM", + parentType = PluginUsageParentType.CASE, + parentKey = "complaint", + parentVersionTag = "1.0.0", + processDefinitionId = "complaint-intake:3:abc", + processDefinitionKey = "complaint-intake", + processDefinitionName = "Complaint intake", + activityId = "SendLetter", + activityName = "Send letter to citizen", + processLinkId = processLinkId, + ) + + val exception = ExternalPluginHostInUseException(hostId, listOf(usage)) + + assertThat(exception.title).isEqualTo("External plugin host is in use") + assertThat(exception.status).isEqualTo(Status.CONFLICT) + assertThat(exception.detail).contains("BPMN process links, case tabs or case widgets reference") + + assertThat(exception.parameters).containsEntry("hostId", hostId.toString()) + + @Suppress("UNCHECKED_CAST") + val payloadUsages = exception.parameters["usages"] as Collection + assertThat(payloadUsages).hasSize(1) + val rendered = payloadUsages.first() + assertThat(rendered.configurationId).isEqualTo(configurationId) + assertThat(rendered.configurationTitle).isEqualTo("Primary CRM") + assertThat(rendered.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(rendered.parentKey).isEqualTo("complaint") + assertThat(rendered.parentVersionTag).isEqualTo("1.0.0") + assertThat(rendered.processDefinitionId).isEqualTo("complaint-intake:3:abc") + assertThat(rendered.processDefinitionKey).isEqualTo("complaint-intake") + assertThat(rendered.processDefinitionName).isEqualTo("Complaint intake") + assertThat(rendered.activityId).isEqualTo("SendLetter") + assertThat(rendered.activityName).isEqualTo("Send letter to citizen") + assertThat(rendered.processLinkId).isEqualTo(processLinkId) + } + + @Test + fun `cause is null so it does not leak into the problem body`() { + val exception = ExternalPluginHostInUseException(UUID.randomUUID(), emptyList()) + assertThat(exception.cause).isNull() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt new file mode 100644 index 0000000000..d55e9999e3 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt @@ -0,0 +1,413 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.preview + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class ExternalPluginImportPreviewContributorTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var contributor: ExternalPluginImportPreviewContributor + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + contributor = ExternalPluginImportPreviewContributor(ObjectMapper(), configurationRepository, definitionRepository) + } + + @Test + fun `contributes an entry for a FIXED external_plugin process link and checks existence`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "externalPluginConfigurationId": "$configId", + "actionKey": "send", + "referenceType": "FIXED", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "1.2.3" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("1.2.3") + assertThat(entry.processDefinitionKey).isEqualTo("my-process") + assertThat(entry.activityId).isEqualTo("Task_1") + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `contributes an entry for an external_plugin_task_form process link`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.existsById(configId)).thenReturn(false) + + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:UserTask:create", + "processLinkType": "external_plugin_task_form", + "externalPluginConfigurationId": "$configId", + "referenceType": "FIXED", + "pluginVersion": "0.1.0" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + assertThat(result.single().existsInTargetEnvironment).isFalse() + assertThat(result.single().source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `ignores BUILDING_BLOCK external plugin references (no configuration id to check)`() { + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "actionKey": "send", + "referenceType": "BUILDING_BLOCK", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "1.2.3" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `ignores embedded plugin process links`() { + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "plugin", + "pluginConfigurationId": "${UUID.randomUUID()}", + "pluginActionDefinitionKey": "create-zaak" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `contributes an entry for an EXTERNAL_PLUGIN case tab with the resolved plugin key and version`() { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + createdAt = Instant.now(), + ) + ) + ) + whenever(definitionRepository.findById(definitionId)).thenReturn( + Optional.of( + ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + ) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `an EXTERNAL_PLUGIN case tab whose configuration is unknown stays a key-less entry`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + assertThat(result.single().pluginDefinitionKey).isNull() + assertThat(result.single().existsInTargetEnvironment).isFalse() + } + + @Test + fun `a self-describing EXTERNAL_PLUGIN case tab stays identifiable when its configuration was deleted`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "0.1.0" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + // Read from the export, not from the (deleted) configuration — so the row is mappable. + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.existsInTargetEnvironment).isFalse() + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `contributes an entry for an external-plugin case widget with the resolved plugin key and version`() { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + createdAt = Instant.now(), + ) + ) + ) + whenever(definitionRepository.findById(definitionId)).thenReturn( + Optional.of( + ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + ) + + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { "type": "fields", "key": "some-fields" }, + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { "configurationId": "$configId", "bundleKey": "summary-widget" } + } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginActionDefinitionKey).isEqualTo("case-widget") + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.activityId).isEqualTo("widgets-tab/summary-widget") + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `a self-describing external-plugin case widget stays identifiable when its configuration was deleted`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { + "configurationId": "$configId", + "bundleKey": "summary-widget", + "pluginDefinitionKey": "case-summary", + "pluginDefinitionVersion": "0.1.0" + } + } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.existsInTargetEnvironment).isFalse() + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `ignores non-external-plugin widgets in a case widget tab`() { + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { "type": "fields", "key": "some-fields" }, + { "type": "custom", "key": "some-custom", "properties": { "componentKey": "x" } } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `ignores non-EXTERNAL_PLUGIN case tabs`() { + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "widgets", + "contentKey": "widget-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `ignores unrelated files`() { + val result = contributor.contributePreview( + mapOf("case/definition/my-doc.case-definition.json" to "{}".toByteArray()) + ) + + assertThat(result).isEmpty() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt new file mode 100644 index 0000000000..800334462e --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.preview.ExternalPluginImportPreviewContributor +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.json.MapperSingleton +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.Instant +import java.util.Optional +import java.util.UUID + +/** + * Round-trips a `FIXED` external plugin process link through the same steps the case + * export/import wizard performs — export DTO → JSON → import preview → deploy DTO with a + * configuration mapping → new link → export DTO → JSON → import preview — pinning that a link + * created *by import* keeps surfacing in the next import's configuration-matching step. + */ +class ExternalPluginProcessLinkExportImportRoundTripTest { + + private val objectMapper = MapperSingleton.get() + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var mapper: ExternalPluginProcessLinkMapper + private lateinit var contributor: ExternalPluginImportPreviewContributor + + private val sourceConfigId = UUID.randomUUID() + private val targetConfigId = UUID.randomUUID() + private val definitionId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + mapper = ExternalPluginProcessLinkMapper( + objectMapper, + configurationRepository, + definitionRepository, + mock(), + ) + contributor = ExternalPluginImportPreviewContributor(objectMapper, configurationRepository, definitionRepository) + + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + val targetConfiguration = ExternalPluginConfiguration( + id = targetConfigId, + definitionId = definitionId, + title = "Target configuration", + createdAt = Instant.now(), + ) + whenever(configurationRepository.findById(targetConfigId)).thenReturn(Optional.of(targetConfiguration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + } + + @Test + fun `a link created by import with a remapped configuration surfaces in the next import preview`() { + // A link as configured through the UI (export #1's source). + val originalLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-v1", + activityId = "my-service-task", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = sourceConfigId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val export1 = export(originalLink) + val preview1 = preview("config/case/bezwaar/1-0-1/process-link/proc.process-link.json", export1) + assertThat(preview1).hasSize(1) + assertThat(preview1.single().pluginConfigurationId).isEqualTo(sourceConfigId) + + // Import export #1 the way ProcessLinkImporter does, with the wizard mapping the source + // configuration to the target configuration. + val importedLink = importLink(export1, mapOf(sourceConfigId to targetConfigId)) + assertThat(importedLink.externalPluginConfigurationId).isEqualTo(targetConfigId) + + // Export the imported case (#2) and run the next import's preview over it. + val export2 = export(importedLink) + val preview2 = preview("config/case/bezwaar-2/1-0-1/process-link/proc.process-link.json", export2) + assertThat(preview2).hasSize(1) + assertThat(preview2.single().pluginConfigurationId).isEqualTo(targetConfigId) + } + + @Test + fun `a link imported dangling (no mapping chosen) is absent from the next import preview`() { + val originalLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-v1", + activityId = "my-service-task", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = sourceConfigId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val importedLink = importLink(export(originalLink), mapOf(sourceConfigId to null)) + assertThat(importedLink.externalPluginConfigurationId).isNull() + + // A dangling link has no configuration id to match, so the preview cannot offer a row. + val preview = preview("config/case/bezwaar-2/1-0-1/process-link/proc.process-link.json", export(importedLink)) + assertThat(preview).isEmpty() + } + + private fun export(link: ExternalPluginProcessLink): ByteArray = + objectMapper.writeValueAsBytes(listOf(mapper.toProcessLinkExportResponseDto(link))) + + private fun preview(fileName: String, content: ByteArray) = + contributor.contributePreview(mapOf(fileName to content)) + + /** The per-node steps of `ProcessLinkImporter.import`. */ + private fun importLink(exportedJson: ByteArray, mappings: Map): ExternalPluginProcessLink { + val tree = objectMapper.readTree(exportedJson.toString(Charsets.UTF_8)) as ArrayNode + val node = tree[0] as ObjectNode + if (!node.has("processDefinitionId")) { + node.set("processDefinitionId", TextNode.valueOf("process-def-v2")) + } + mapper.applyPluginConfigurationMappings(node, mappings) + val deployDto = objectMapper.treeToValue(node, ProcessLinkDeployDto::class.java) + val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, null) + return mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt new file mode 100644 index 0000000000..139c4294df --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationContext +import com.ritense.externalplugin.BaseIntegrationTest +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.importer.ImportRequest +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.importer.ProcessLinkImporter +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.util.UUID + +@Transactional +class ExternalPluginProcessLinkImportIntTest @Autowired constructor( + private val processLinkImporter: ProcessLinkImporter, + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val hostRepository: ExternalPluginHostRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val repositoryService: OperatonRepositoryService, +) : BaseIntegrationTest() { + + @Test + fun `import maps a FIXED external plugin link to the seeded configuration`() { + val configuration = seedConfiguration() + val sourceConfigurationId = UUID.randomUUID() + + processLinkImporter.import( + ImportRequest( + "/process-link/$PROCESS_DEFINITION_KEY.process-link.json", + fixture(sourceConfigurationId).toByteArray(Charsets.UTF_8), + null, + null, + null, + null, + mapOf(sourceConfigurationId to configuration.id), + ) + ) + + val processDefinition = getLatestProcessDefinition() + val processLink = requireNotNull( + processLinkRepository.findByProcessDefinitionId(processDefinition).singleOrNull() + ) + + assertThat(processLink.externalPluginConfigurationId).isEqualTo(configuration.id) + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo(PLUGIN_ID) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo(PLUGIN_VERSION) + assertThat(processLink.actionKey).isEqualTo(ACTION_KEY) + assertThat(processLink.actionResultMappings).isEmpty() + } + + @Test + fun `import without a target mapping leaves the configuration id dangling`() { + val sourceConfigurationId = UUID.randomUUID() + + processLinkImporter.import( + ImportRequest( + "/process-link/$PROCESS_DEFINITION_KEY.process-link.json", + fixture(sourceConfigurationId).toByteArray(Charsets.UTF_8), + null, + null, + null, + null, + mapOf(sourceConfigurationId to null), + ) + ) + + val processDefinition = getLatestProcessDefinition() + val processLink = requireNotNull( + processLinkRepository.findByProcessDefinitionId(processDefinition).singleOrNull() + ) + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + } + + private fun seedConfiguration(): ExternalPluginConfiguration { + val host = hostRepository.save( + ExternalPluginHost( + id = UUID.randomUUID(), + name = "Test host", + baseUrl = "http://localhost:1234", + secret = "secret", + status = ExternalPluginHostStatus.CONNECTED, + ) + ) + val definition = definitionRepository.save( + ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = PLUGIN_ID, + version = PLUGIN_VERSION, + hostId = host.id, + baseUrl = host.baseUrl, + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + return configurationRepository.save( + ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definition.id, + title = "Test configuration", + createdAt = Instant.now(), + ) + ) + } + + private fun getLatestProcessDefinition(): String { + return AuthorizationContext.runWithoutAuthorization { + requireNotNull(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).id + } + } + + private fun fixture(externalPluginConfigurationId: UUID): String { + // processDefinitionId is intentionally absent — ProcessLinkImporter resolves the latest + // deployed definition for the file's key and sets it on the node itself. + return """ + [ + { + "activityId": "my-service-task", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "externalPluginConfigurationId": "$externalPluginConfigurationId", + "actionKey": "$ACTION_KEY", + "referenceType": "FIXED", + "actionResultMappings": [] + } + ] + """.trimIndent() + } + + private companion object { + const val PROCESS_DEFINITION_KEY = "external-plugin-import-process" + const val PLUGIN_ID = "test-plugin" + const val PLUGIN_VERSION = "1.0.0" + const val ACTION_KEY = "test-action" + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt new file mode 100644 index 0000000000..2cb43c637c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt @@ -0,0 +1,598 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkDeployDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkExportResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valueresolver.exception.ValueResolverValidationException +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class ExternalPluginProcessLinkMapperTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + private lateinit var mapper: ExternalPluginProcessLinkMapper + + private val definitionId = UUID.randomUUID() + private val configId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + processLinkRepository = mock() + mapper = ExternalPluginProcessLinkMapper( + ObjectMapper(), + configurationRepository, + definitionRepository, + processLinkRepository, + ) + + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Case summary configuration", + createdAt = Instant.now(), + ) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + } + + @Test + fun `create accepts action result mappings whose sources match declared action outputs`() { + val definitionWithOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", listOf("summary", "title")), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/summary", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + @Test + fun `create rejects an action result mapping source that is not a declared action output`() { + val definitionWithOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", listOf("summary", "title")), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/unknownKey", target = "doc:/summary")), + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(ValueResolverValidationException::class.java) + .hasMessageContaining("does not match a declared output") + } + + @Test + fun `create rejects any action result mapping when the action declares no outputs`() { + val definitionWithoutOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", emptyList()), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithoutOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/summary", target = "doc:/summary")), + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(ValueResolverValidationException::class.java) + .hasMessageContaining("does not declare any outputs") + } + + @Test + fun `create is lenient and skips source validation when the definition cannot be resolved`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginVersion = "1.0.0", + actionResultMappings = listOf(PluginActionResultMapping(source = "/anything", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + @Test + fun `create is lenient and skips source validation when the resolved definition has no manifest`() { + val definitionWithoutManifest = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = null, + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithoutManifest)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/anything", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + private fun manifestWithActionOutputs(actionKey: String, outputs: List): com.fasterxml.jackson.databind.node.ObjectNode { + val objectMapper = ObjectMapper() + val manifest = objectMapper.createObjectNode() + val actions = manifest.putArray("actions") + val action = actions.addObject() + action.put("key", actionKey) + val outputsArray = action.putArray("outputs") + outputs.forEach { outputsArray.add(it) } + return manifest + } + + @Test + fun `supports only the external_plugin link type`() { + assertThat(mapper.supportsProcessLinkType("external_plugin")).isTrue() + assertThat(mapper.supportsProcessLinkType("external_plugin_task_form")).isFalse() + assertThat(mapper.supportsProcessLinkType("plugin")).isFalse() + } + + @Test + fun `FIXED create derives pluginDefinitionKey and pluginVersion from the configuration, ignoring any values on the dto`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + // deliberately wrong/stale values — must be overridden by the derived configuration + pluginDefinitionKey = "some-other-plugin", + pluginVersion = "9.9.9", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isEqualTo(configId) + assertThat(processLink.actionKey).isEqualTo("send") + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + } + + @Test + fun `FIXED create falls back to dto-supplied key and version when the configuration cannot be resolved (dangling import)`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + pluginDefinitionKey = "case-summary", + pluginVersion = "1.0.0", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.0.0") + } + + @Test + fun `FIXED create allows a null configuration id (dangling import placeholder)`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isNull() + } + + @Test + fun `BUILDING_BLOCK create requires pluginDefinitionKey and pluginVersion from the dto and rejects a configuration id`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginVersion = "2.0.0", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.BUILDING_BLOCK) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("2.0.0") + } + + @Test + fun `BUILDING_BLOCK create rejects a non-null configuration id`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginVersion = "2.0.0", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("externalPluginConfigurationId must be empty") + } + + @Test + fun `BUILDING_BLOCK create requires pluginDefinitionKey`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginVersion = "2.0.0", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("pluginDefinitionKey is required") + } + + @Test + fun `BUILDING_BLOCK create requires pluginVersion`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("pluginVersion is required") + } + + @Test + fun `FIXED update derives pluginDefinitionKey and pluginVersion from the configuration`() { + val id = UUID.randomUUID() + val existing = ExternalPluginProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = UUID.randomUUID(), + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "old-plugin", + pluginDefinitionVersion = "0.0.1", + ), + ) + val updateDto = ExternalPluginProcessLinkUpdateRequestDto( + id = id, + externalPluginConfigurationId = configId, + actionKey = "send", + ) + + val updated = mapper.toUpdatedProcessLink(existing, updateDto, null) as ExternalPluginProcessLink + + assertThat(updated.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updated.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(updated.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a process link to a response dto exposing the reference fields`() { + val id = UUID.randomUUID() + val processLink = ExternalPluginProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + + val dto = mapper.toProcessLinkResponseDto(processLink) as ExternalPluginProcessLinkResponseDto + + assertThat(dto.id).isEqualTo(id) + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.actionKey).isEqualTo("send") + assertThat(dto.referenceType).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(dto.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.pluginVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a process link to an export response dto exposing the reference fields`() { + val processLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + + val dto = mapper.toProcessLinkExportResponseDto(processLink) as ExternalPluginProcessLinkExportResponseDto + + assertThat(dto.activityId).isEqualTo("activity-1") + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.actionKey).isEqualTo("send") + assertThat(dto.referenceType).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(dto.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.pluginVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a deploy dto to a create request and back to an update request`() { + val deployDto = ExternalPluginProcessLinkDeployDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + ) + + val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, null) + as ExternalPluginProcessLinkCreateRequestDto + assertThat(createDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(createDto.actionKey).isEqualTo("send") + + val existingId = UUID.randomUUID() + val updateDto = mapper.toProcessLinkUpdateRequestDto(deployDto, existingId, null) + as ExternalPluginProcessLinkUpdateRequestDto + assertThat(updateDto.id).isEqualTo(existingId) + assertThat(updateDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updateDto.actionKey).isEqualTo("send") + } + + @Test + fun `applyPluginConfigurationMappings rewrites externalPluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings nulls externalPluginConfigurationId when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("externalPluginConfigurationId").isNull).isTrue() + } + + @Test + fun `afterImport emits detected event when FIXED link has null externalPluginConfigurationId`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport emits detected event when FIXED link configuration no longer exists`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + } + + @Test + fun `afterImport emits resolved event when all FIXED links have existing configurations`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport ignores BUILDING_BLOCK links`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt new file mode 100644 index 0000000000..d3fe07761c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.module.kotlin.readValue +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkUpdateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkUpdateRequestDto +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto +import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto +import com.ritense.valtimo.contract.json.MapperSingleton +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import java.util.UUID + +/** + * The process-link framework resolves the concrete subtype of a create/update request by Jackson + * DEDUCTION (`@JsonTypeInfo(use = DEDUCTION)`: which fields are present, not an explicit + * `processLinkType`). The external-plugin service-task action link is identified by its `actionKey`, + * the task-form link by its `bundleKey`. Because every other field of the task-form link is a strict + * subset of the action link's, `bundleKey` MUST always be serialised (even as null) for the task-form + * to stay distinguishable — the frontend upholds this. These tests lock that contract so a later field + * change can't silently make the two links ambiguous. + */ +class ExternalPluginProcessLinkTypeDeductionTest { + + // Register both mappers' subtypes on one ObjectMapper, exactly as the auto-configuration does, so + // deduction sees the same candidate set as at runtime. + private val mapper = MapperSingleton.get().copy().also { + ExternalPluginProcessLinkMapper(it, mock(), mock(), mock()) + ExternalPluginTaskFormProcessLinkMapper(it, mock(), mock(), mock()) + } + + @Test + fun `deduces the action create request from its actionKey`() { + val dto: ProcessLinkCreateRequestDto = mapper.readValue( + """ + { + "processDefinitionId": "pd-1", + "activityId": "SendLetter", + "activityType": "${ActivityTypeWithEventName.SERVICE_TASK_START.value}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "actionKey": "send", + "pluginVersion": "1.0.0" + } + """.trimIndent() + ) + + assertThat(dto).isInstanceOf(ExternalPluginProcessLinkCreateRequestDto::class.java) + } + + @Test + fun `deduces the task-form create request from its bundleKey`() { + val dto: ProcessLinkCreateRequestDto = mapper.readValue( + """ + { + "processDefinitionId": "pd-1", + "activityId": "ReviewTask", + "activityType": "${ActivityTypeWithEventName.USER_TASK_CREATE.value}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "pluginVersion": "1.0.0", + "bundleKey": "review" + } + """.trimIndent() + ) + + assertThat(dto).isInstanceOf(ExternalPluginTaskFormProcessLinkCreateRequestDto::class.java) + } + + @Test + fun `deduces the task-form create request even when the bundleKey is null`() { + val dto: ProcessLinkCreateRequestDto = mapper.readValue( + """ + { + "processDefinitionId": "pd-1", + "activityId": "ReviewTask", + "activityType": "${ActivityTypeWithEventName.USER_TASK_CREATE.value}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "pluginVersion": "1.0.0", + "bundleKey": null + } + """.trimIndent() + ) + + assertThat(dto).isInstanceOf(ExternalPluginTaskFormProcessLinkCreateRequestDto::class.java) + assertThat((dto as ExternalPluginTaskFormProcessLinkCreateRequestDto).bundleKey).isNull() + } + + @Test + fun `omitting the bundleKey makes the task-form create request indistinguishable from the action link`() { + // Without bundleKey the payload's fields are a subset of the action link's, so deduction can + // never resolve the task-form subtype from them — this is precisely why the frontend always + // serialises bundleKey. Depending on the candidate set Jackson either fails or resolves to the + // action link, but never silently to the task-form. + val result = runCatching { + mapper.readValue( + """ + { + "processDefinitionId": "pd-1", + "activityId": "ReviewTask", + "activityType": "${ActivityTypeWithEventName.USER_TASK_CREATE.value}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "pluginVersion": "1.0.0" + } + """.trimIndent() + ) + }.getOrNull() + + assertThat(result is ExternalPluginTaskFormProcessLinkCreateRequestDto).isFalse() + } + + @Test + fun `deduces the action update request from its actionKey`() { + val dto: ProcessLinkUpdateRequestDto = mapper.readValue( + """ + { + "id": "${UUID.randomUUID()}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "actionKey": "send", + "pluginVersion": "1.0.0" + } + """.trimIndent() + ) + + assertThat(dto).isInstanceOf(ExternalPluginProcessLinkUpdateRequestDto::class.java) + } + + @Test + fun `deduces the task-form update request from its bundleKey`() { + val dto: ProcessLinkUpdateRequestDto = mapper.readValue( + """ + { + "id": "${UUID.randomUUID()}", + "externalPluginConfigurationId": "${UUID.randomUUID()}", + "pluginVersion": "1.0.0", + "bundleKey": null + } + """.trimIndent() + ) + + assertThat(dto).isInstanceOf(ExternalPluginTaskFormProcessLinkUpdateRequestDto::class.java) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt new file mode 100644 index 0000000000..f64a411a30 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt @@ -0,0 +1,565 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.exception.ExternalPluginActionFailedException +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.PluginActionResultHandler +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.event.OperatonExecutionEvent +import com.ritense.valueresolver.ValueResolverService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.delegate.BpmnError +import org.operaton.bpm.engine.delegate.DelegateExecution +import java.util.UUID + +class ExternalPluginServiceTaskStartListenerTest { + + private lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + private lateinit var configurationService: ExternalPluginConfigurationService + private lateinit var definitionService: ExternalPluginDefinitionService + private lateinit var hostService: ExternalPluginHostService + private lateinit var hostClient: ExternalPluginHostClient + private lateinit var valueResolverService: ValueResolverService + private lateinit var objectMapper: ObjectMapper + private lateinit var pluginActionResultHandler: PluginActionResultHandler + private lateinit var listener: ExternalPluginServiceTaskStartListener + + private val configurationId = UUID.randomUUID() + private val definitionId = UUID.randomUUID() + private val hostId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + processLinkRepository = mock() + configurationService = mock() + definitionService = mock() + hostService = mock() + hostClient = mock() + valueResolverService = mock() + objectMapper = ObjectMapper() + pluginActionResultHandler = mock() + listener = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + ) + + val configuration = mock { + on { id } doReturn configurationId + on { this.definitionId } doReturn definitionId + } + val definition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + } + val host = mock { + on { baseUrl } doReturn "http://localhost:8090" + } + whenever(configurationService.get(configurationId)).thenReturn(configuration) + whenever(definitionService.get(definitionId)).thenReturn(definition) + whenever(hostService.get(hostId)).thenReturn(host) + whenever(hostService.decryptedSecret(host)).thenReturn("secret") + } + + /** + * A global (no-case) process has a null business key, so a case-bound plugin returns a 4xx. The + * listener must surface the plugin's real error as a plain [ExternalPluginActionFailedException] + * — never a BpmnError, which the @Transactional event bridge would mask as an opaque + * "Transaction silently rolled back" incident (see #769). + */ + @Test + fun `4xx plugin error surfaces as ExternalPluginActionFailedException with the real message`() { + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 422, + body = objectMapper.readTree( + """{"errorCode":"NO_BUSINESS_KEY","errorMessage":"Process has no business key — case-summary requires a case-bound process"}""", + ), + ), + ) + + val event = globalProcessServiceTaskEvent() + + assertThatThrownBy { listener.notify(event) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .isNotInstanceOf(BpmnError::class.java) + .hasMessageContaining("NO_BUSINESS_KEY") + .hasMessageContaining("Process has no business key") + .hasMessageContaining("422") + + val exception = runCatching { listener.notify(globalProcessServiceTaskEvent()) }.exceptionOrNull() + assertThat((exception as ExternalPluginActionFailedException).errorCode).isEqualTo("NO_BUSINESS_KEY") + } + + @Test + fun `5xx host error surfaces as ExternalPluginActionFailedException`() { + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 500, body = null), + ) + + assertThatThrownBy { listener.notify(globalProcessServiceTaskEvent()) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .isNotInstanceOf(BpmnError::class.java) + .hasMessageContaining("500") + .hasMessageContaining("case-summary") + } + + @Test + fun `refuses to invoke a plugin whose changed content awaits re-acceptance`() { + val changedDefinition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + on { requiresReacceptance } doReturn true + } + whenever(definitionService.get(definitionId)).thenReturn(changedDefinition) + + val thrown = runCatching { listener.notify(globalProcessServiceTaskEvent()) }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(ExternalPluginActionFailedException::class.java) + assertThat((thrown as ExternalPluginActionFailedException).errorCode) + .isEqualTo(ExternalPluginServiceTaskStartListener.CONTENT_CHANGED_ERROR_CODE) + assertThat(thrown).hasMessageContaining("awaits re-acceptance") + verify(hostClient, never()).invokeAction(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `BUILDING_BLOCK reference resolves the configuration via the namespaced key`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.1.0")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(resolver).resolve(execution, "external-plugin:case-summary@0.1.0") + verify(hostClient).invokeAction( + baseUrl = eq("http://localhost:8090"), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = eq("case-summary"), + payload = any(), + hostSecret = eq("secret"), + ) + } + + @Test + fun `BUILDING_BLOCK reference with mismatched pluginId throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // configurationId resolves to a definition with pluginId "case-summary" (see setUp), + // but the reference expects a different plugin — must not silently invoke the wrong plugin. + val processLink = buildingBlockProcessLink(pluginId = "other-plugin", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:other-plugin@0.1.0")).thenReturn(configurationId) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("other-plugin") + .hasMessageContaining("case-summary") + } + + @Test + fun `BUILDING_BLOCK reference with a version mismatch proceeds with the resolved configuration's version`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // setUp wires the definition to version "0.1.0"; the reference is pinned to "0.0.9". + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.0.9") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.0.9")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(hostClient).invokeAction( + baseUrl = any(), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = any(), + payload = any(), + hostSecret = any(), + ) + } + + @Test + fun `BUILDING_BLOCK reference falls back to a mapping made for a different version of the same plugin`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // The reference pins 0.2.0, but only a mapping for another version (the wired-up 0.1.0) exists. + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.2.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.2.0")).thenReturn(null) + whenever(resolver.resolveByKeyPrefix(execution, "external-plugin:case-summary@")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(resolver).resolveByKeyPrefix(execution, "external-plugin:case-summary@") + verify(hostClient).invokeAction( + baseUrl = any(), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = any(), + payload = any(), + hostSecret = any(), + ) + } + + @Test + fun `BUILDING_BLOCK reference to a definition whose manifest no longer declares the action key throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val staleDefinitionId = UUID.randomUUID() + val staleConfigurationId = UUID.randomUUID() + val staleConfiguration = mock { + on { id } doReturn staleConfigurationId + on { this.definitionId } doReturn staleDefinitionId + } + val staleDefinition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.2.0" + on { manifestJson } doReturn objectMapper.readTree( + """{"actions":[{"key":"some-other-action"}]}""", + ) as com.fasterxml.jackson.databind.node.ObjectNode + } + whenever(configurationService.get(staleConfigurationId)).thenReturn(staleConfiguration) + whenever(definitionService.get(staleDefinitionId)).thenReturn(staleDefinition) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.2.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.2.0")).thenReturn(staleConfigurationId) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("case-summary") + .hasMessageContaining("does not declare") + } + + @Test + fun `a result missing a manifest-declared output key fails with RESULT_CONTRACT_VIOLATION`() { + stubDefinitionWithDeclaredOutputs("summary", "title") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 200, + body = objectMapper.readTree("""{"result":{"summary":"a summary"}}"""), + ), + ) + + assertThatThrownBy { listener.notify(globalProcessServiceTaskEvent()) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .hasMessageContaining("title") + .hasMessageContaining("declares outputs") + + val exception = runCatching { listener.notify(globalProcessServiceTaskEvent()) }.exceptionOrNull() + assertThat((exception as ExternalPluginActionFailedException).errorCode) + .isEqualTo("RESULT_CONTRACT_VIOLATION") + verify(pluginActionResultHandler, never()).handle(any(), any(), any()) + } + + @Test + fun `a response without a result object fails when the manifest declares outputs`() { + stubDefinitionWithDeclaredOutputs("summary") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + assertThatThrownBy { listener.notify(globalProcessServiceTaskEvent()) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .hasMessageContaining("summary") + } + + @Test + fun `declared output keys returned as null pass validation`() { + stubDefinitionWithDeclaredOutputs("summary", "title") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 200, + body = objectMapper.readTree("""{"result":{"summary":null,"title":null}}"""), + ), + ) + + listener.notify(globalProcessServiceTaskEvent()) + } + + @Test + fun `a definition without declared outputs skips result validation`() { + // setUp's definition has no manifestJson at all — a 200 without any result must pass. + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listener.notify(globalProcessServiceTaskEvent()) + } + + private fun stubDefinitionWithDeclaredOutputs(vararg outputs: String) { + val outputsJson = outputs.joinToString(",") { "\"$it\"" } + val definition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + on { manifestJson } doReturn objectMapper.readTree( + """{"actions":[{"key":"case-summary","outputs":[$outputsJson]}]}""", + ) as com.fasterxml.jackson.databind.node.ObjectNode + } + whenever(definitionService.get(definitionId)).thenReturn(definition) + } + + @Test + fun `BUILDING_BLOCK reference without a resolver bean throws a clear error`() { + // listener from setUp() was constructed with a null resolver (the default) + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + assertThatThrownBy { listener.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("resolver") + } + + @Test + fun `BUILDING_BLOCK reference with no mapping throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.1.0")).thenReturn(null) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("external-plugin:case-summary@0.1.0") + } + + /** + * A literal action property that merely contains a colon (`https://…`) must reach the plugin + * untouched: only values whose prefix an actual resolver factory supports go through the + * value-resolver service. + */ + @Test + fun `literal action property with a colon is passed through while resolver-supported values are resolved`() { + val actionProperties = objectMapper.readTree( + """{"callbackUrl":"https://example.com/callback","userName":"pv:userName"}""", + ) as com.fasterxml.jackson.databind.node.ObjectNode + val processLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-id", + activityId = "ServiceTask_ExternalPlugin", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configurationId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + actionProperties = actionProperties, + ) + val execution = executionFor(processLink) + + whenever(valueResolverService.supportsValue("https://example.com/callback")).thenReturn(false) + whenever(valueResolverService.supportsValue("pv:userName")).thenReturn(true) + whenever(valueResolverService.resolveValues(eq("process-instance-id"), eq(execution), eq(listOf("pv:userName")))) + .thenReturn(mapOf("pv:userName" to "Alice")) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listener.notify(OperatonExecutionEvent(execution, "start")) + + val payloadCaptor = argumentCaptor() + verify(hostClient).invokeAction(any(), any(), any(), any(), payloadCaptor.capture(), any()) + val properties = payloadCaptor.firstValue.get("properties") + assertThat(properties.get("callbackUrl").asText()).isEqualTo("https://example.com/callback") + assertThat(properties.get("userName").asText()).isEqualTo("Alice") + // The literal was never sent through the resolver. + verify(valueResolverService).resolveValues(eq("process-instance-id"), eq(execution), eq(listOf("pv:userName"))) + } + + private fun buildingBlockProcessLink(pluginId: String, version: String): ExternalPluginProcessLink = + ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-id", + activityId = "ServiceTask_ExternalPlugin", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = pluginId, + pluginDefinitionVersion = version, + ), + actionProperties = null, + ) + + private fun executionFor(processLink: ExternalPluginProcessLink): DelegateExecution { + whenever( + processLinkRepository.findByProcessDefinitionIdAndActivityIdAndActivityType( + "process-def-id", + "ServiceTask_ExternalPlugin", + ActivityTypeWithEventName.SERVICE_TASK_START, + ), + ).thenReturn(listOf(processLink)) + + return mock { + on { processDefinitionId } doReturn "process-def-id" + on { currentActivityId } doReturn "ServiceTask_ExternalPlugin" + on { processInstanceId } doReturn "process-instance-id" + on { processBusinessKey } doReturn "business-key" + } + } + + private fun globalProcessServiceTaskEvent(): OperatonExecutionEvent { + val processLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-id", + activityId = "ServiceTask_ExternalPlugin", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configurationId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + actionProperties = null, + ) + whenever( + processLinkRepository.findByProcessDefinitionIdAndActivityIdAndActivityType( + "process-def-id", + "ServiceTask_ExternalPlugin", + ActivityTypeWithEventName.SERVICE_TASK_START, + ), + ).thenReturn(listOf(processLink)) + + val execution = mock { + on { processDefinitionId } doReturn "process-def-id" + on { currentActivityId } doReturn "ServiceTask_ExternalPlugin" + on { processInstanceId } doReturn "process-instance-id" + on { processBusinessKey } doReturn null // global process: no case / document + } + return OperatonExecutionEvent(execution, "start") + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt new file mode 100644 index 0000000000..6d23071e08 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.operaton.domain.OperatonExecution +import com.ritense.valtimo.operaton.domain.OperatonTask +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.LocalDateTime +import java.util.UUID + +class ExternalPluginTaskFormProcessLinkActivityHandlerTest { + + private val bundleResolver = mock() + private val handler = ExternalPluginTaskFormProcessLinkActivityHandler(bundleResolver) + + @Test + fun `supports only the task-form process link`() { + assertThat(handler.supports(taskFormLink())).isTrue() + assertThat( + handler.supports( + ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = UUID.randomUUID(), + actionKey = "some-action", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + ) + ).isFalse() + } + + @Test + fun `openTask returns the task-form render descriptor with task context`() { + val configId = UUID.randomUUID() + val processLink = taskFormLink(configurationId = configId, bundleKey = "review") + whenever(bundleResolver.resolve(configId, "task-form", "review")) + .thenReturn("http://host:8090/plugins/case-summary/0.1.0/bundles/task-form.html") + + val due = LocalDateTime.now() + val processInstance = mock() + whenever(processInstance.id).thenReturn("process-instance-1") + whenever(processInstance.businessKey).thenReturn("document-1") + val task = mock() + whenever(task.id).thenReturn("task-1") + whenever(task.assignee).thenReturn("john") + whenever(task.dueDate).thenReturn(due) + whenever(task.processInstance).thenReturn(processInstance) + + val result = handler.openTask(task, processLink) + + assertThat(result.processLinkId).isEqualTo(processLink.id) + assertThat(result.type).isEqualTo("external-plugin-task-form") + assertThat(result.assignee).isEqualTo("john") + assertThat(result.due).isEqualTo(due) + assertThat(result.properties.bundleUrl) + .isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/task-form.html") + assertThat(result.properties.configurationId).isEqualTo(configId) + assertThat(result.properties.bundleKey).isEqualTo("review") + assertThat(result.properties.context.taskId).isEqualTo("task-1") + assertThat(result.properties.context.processInstanceId).isEqualTo("process-instance-1") + assertThat(result.properties.context.documentId).isEqualTo("document-1") + assertThat(result.properties.context.pluginConfigurationId).isEqualTo(configId.toString()) + } + + @Test + fun `getStartEventObject returns the descriptor without a task id`() { + val configId = UUID.randomUUID() + val documentId = UUID.randomUUID() + val processLink = taskFormLink(configurationId = configId, bundleKey = null) + whenever(bundleResolver.resolve(configId, "task-form", null)) + .thenReturn("http://host:8090/plugins/case-summary/0.1.0/bundles/task-form.html") + + val result = handler.getStartEventObject("pd-1", documentId, "some-case", processLink) + + assertThat(result.type).isEqualTo("external-plugin-task-form") + assertThat(result.assignee).isNull() + assertThat(result.properties.context.taskId).isNull() + assertThat(result.properties.context.documentId).isEqualTo(documentId.toString()) + } + + private fun taskFormLink( + configurationId: UUID = UUID.randomUUID(), + bundleKey: String? = "review", + ) = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configurationId, + bundleKey = bundleKey, + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt new file mode 100644 index 0000000000..41526d25f7 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt @@ -0,0 +1,354 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkDeployDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkExportResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class ExternalPluginTaskFormProcessLinkMapperTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var processLinkRepository: ExternalPluginTaskFormProcessLinkRepository + private lateinit var mapper: ExternalPluginTaskFormProcessLinkMapper + + private val definitionId = UUID.randomUUID() + private val configId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + processLinkRepository = mock() + mapper = ExternalPluginTaskFormProcessLinkMapper(ObjectMapper(), configurationRepository, definitionRepository, processLinkRepository) + + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Case summary configuration", + createdAt = Instant.now(), + ) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + } + + @Test + fun `supports only the task-form link type`() { + assertThat(mapper.supportsProcessLinkType("external_plugin_task_form")).isTrue() + assertThat(mapper.supportsProcessLinkType("external_plugin")).isFalse() + assertThat(mapper.supportsProcessLinkType("form")).isFalse() + } + + @Test + fun `maps a create request to a new process link, deriving the reference from the configuration`() { + val createDto = ExternalPluginTaskFormProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + // deliberately stale — must be overridden by the derived configuration + pluginVersion = "9.9.9", + bundleKey = "review", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginTaskFormProcessLink + + assertThat(processLink.processDefinitionId).isEqualTo("pd-1") + assertThat(processLink.activityId).isEqualTo("activity-1") + assertThat(processLink.activityType).isEqualTo(ActivityTypeWithEventName.USER_TASK_CREATE) + assertThat(processLink.externalPluginConfigurationId).isEqualTo(configId) + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + assertThat(processLink.bundleKey).isEqualTo("review") + assertThat(processLink.processLinkType).isEqualTo("external_plugin_task_form") + } + + @Test + fun `create falls back to the dto-supplied pluginVersion when the configuration cannot be resolved (dangling import)`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginTaskFormProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = danglingConfigId, + pluginVersion = "1.0.0", + bundleKey = "review", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginTaskFormProcessLink + + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.0.0") + } + + @Test + fun `maps a process link to a response dto`() { + val id = UUID.randomUUID() + val processLink = ExternalPluginTaskFormProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val dto = mapper.toProcessLinkResponseDto(processLink) as ExternalPluginTaskFormProcessLinkResponseDto + + assertThat(dto.id).isEqualTo(id) + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.bundleKey).isEqualTo("review") + assertThat(dto.pluginVersion).isEqualTo("0.1.0") + assertThat(dto.processLinkType).isEqualTo("external_plugin_task_form") + } + + @Test + fun `maps an update request onto the existing process link, deriving the reference from the new configuration`() { + val id = UUID.randomUUID() + val existing = ExternalPluginTaskFormProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = UUID.randomUUID(), + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "old-plugin", + pluginDefinitionVersion = "0.0.1", + ), + ) + val updateDto = ExternalPluginTaskFormProcessLinkUpdateRequestDto( + id = id, + externalPluginConfigurationId = configId, + pluginVersion = "0.2.0", + bundleKey = "approve", + ) + + val updated = mapper.toUpdatedProcessLink(existing, updateDto, null) as ExternalPluginTaskFormProcessLink + + assertThat(updated.id).isEqualTo(id) + assertThat(updated.processDefinitionId).isEqualTo("pd-1") + assertThat(updated.activityId).isEqualTo("activity-1") + assertThat(updated.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updated.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(updated.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + assertThat(updated.bundleKey).isEqualTo("approve") + } + + @Test + fun `maps a deploy dto to a create request`() { + val deployDto = ExternalPluginTaskFormProcessLinkDeployDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + pluginVersion = "0.1.0", + bundleKey = "review", + ) + + val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, null) + as ExternalPluginTaskFormProcessLinkCreateRequestDto + + assertThat(createDto.processDefinitionId).isEqualTo("pd-1") + assertThat(createDto.activityId).isEqualTo("activity-1") + assertThat(createDto.activityType).isEqualTo(ActivityTypeWithEventName.USER_TASK_CREATE) + assertThat(createDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(createDto.pluginVersion).isEqualTo("0.1.0") + assertThat(createDto.bundleKey).isEqualTo("review") + assertThat(createDto.processLinkType).isEqualTo("external_plugin_task_form") + } + + @Test + fun `maps a deploy dto to an update request onto the existing link id`() { + val existingId = UUID.randomUUID() + val deployDto = ExternalPluginTaskFormProcessLinkDeployDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + pluginVersion = "0.1.0", + bundleKey = "review", + ) + + val updateDto = mapper.toProcessLinkUpdateRequestDto(deployDto, existingId, null) + as ExternalPluginTaskFormProcessLinkUpdateRequestDto + + assertThat(updateDto.id).isEqualTo(existingId) + assertThat(updateDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updateDto.pluginVersion).isEqualTo("0.1.0") + assertThat(updateDto.bundleKey).isEqualTo("review") + assertThat(updateDto.processLinkType).isEqualTo("external_plugin_task_form") + } + + @Test + fun `maps a process link to an export response dto without the process definition id`() { + val processLink = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val dto = mapper.toProcessLinkExportResponseDto(processLink) + as ExternalPluginTaskFormProcessLinkExportResponseDto + + assertThat(dto.activityId).isEqualTo("activity-1") + assertThat(dto.activityType).isEqualTo(ActivityTypeWithEventName.USER_TASK_CREATE) + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.bundleKey).isEqualTo("review") + assertThat(dto.pluginVersion).isEqualTo("0.1.0") + assertThat(dto.processLinkType).isEqualTo("external_plugin_task_form") + } + + @Test + fun `maps an unkeyed bundle create request with a null bundle key`() { + val createDto = ExternalPluginTaskFormProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + pluginVersion = "0.1.0", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginTaskFormProcessLink + + assertThat(processLink.bundleKey).isNull() + } + + @Test + fun `applyPluginConfigurationMappings rewrites externalPluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings leaves externalPluginConfigurationId unchanged when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `afterImport emits detected event when the referenced configuration no longer exists`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + val link = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = danglingConfigId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport emits resolved event when all referenced configurations exist`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + val link = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt new file mode 100644 index 0000000000..3a405edf03 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationService +import com.ritense.document.service.impl.JsonSchemaDocumentService +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.operaton.domain.OperatonTask +import com.ritense.valtimo.service.OperatonTaskService +import com.ritense.valueresolver.ValueResolverService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.UUID + +class ExternalPluginTaskFormSubmissionServiceTest { + + private lateinit var processLinkService: ProcessLinkService + private lateinit var configurationService: ExternalPluginConfigurationService + private lateinit var definitionService: ExternalPluginDefinitionService + private lateinit var hostService: ExternalPluginHostService + private lateinit var hostClient: ExternalPluginHostClient + private lateinit var processDocumentService: ProcessDocumentService + private lateinit var documentService: JsonSchemaDocumentService + private lateinit var operatonTaskService: OperatonTaskService + private lateinit var authorizationService: AuthorizationService + private lateinit var valueResolverService: ValueResolverService + private lateinit var objectMapper: ObjectMapper + private lateinit var service: ExternalPluginTaskFormSubmissionService + + private val processLinkId = UUID.randomUUID() + private val configurationId = UUID.randomUUID() + private val definitionId = UUID.randomUUID() + private val hostId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + processLinkService = mock() + configurationService = mock() + definitionService = mock() + hostService = mock() + hostClient = mock() + processDocumentService = mock() + documentService = mock() + operatonTaskService = mock() + authorizationService = mock() + valueResolverService = mock() + objectMapper = ObjectMapper() + service = ExternalPluginTaskFormSubmissionService( + processLinkService, + configurationService, + definitionService, + hostService, + hostClient, + processDocumentService, + documentService, + operatonTaskService, + authorizationService, + valueResolverService, + objectMapper, + ) + } + + @Test + fun `Level 1 hook rejection surfaces field errors and never completes the task`() { + givenProcessLink(bundleKey = "review") + givenTask() + givenManifestWithTaskFormBundle(key = "review", submitHandler = true) + givenHook() + whenever(hostClient.invokeSubmit(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 422, + body = objectMapper.readTree( + """{"status":"error","errorMessage":"A comment is required when rejecting.","fieldErrors":{"comment":"Please explain."}}""", + ), + ), + ) + + val result = service.handleSubmission( + processLinkId, + objectMapper.readTree("""{"decision":"reject","comment":""}"""), + documentId = "doc-1", + taskInstanceId = "task-1", + ) + + assertThat(result.fieldErrors).containsEntry("comment", "Please explain.") + assertThat(result.errors).contains("A comment is required when rejecting.") + // The task must NOT be completed when the plugin rejects the submission. + verify(processDocumentService, never()).dispatch(any()) + verify(operatonTaskService, never()).completeTaskWithFormData(any(), any()) + } + + @Test + fun `Level 0 without a submit handler categorizes values and completes the task itself (no document)`() { + givenProcessLink(bundleKey = "approve") + givenTask() + // Bundle exists but does not declare a submit handler → the hook is skipped (Level 0). + givenManifestWithTaskFormBundle(key = "approve", submitHandler = false) + + val result = service.handleSubmission( + processLinkId, + objectMapper.readTree("""{"caseApproved":true,"pv:score":5}"""), + documentId = null, + taskInstanceId = "task-1", + ) + + // No plugin backend was called, and GZAC completed the task with the categorized variables. + verify(hostClient, never()).invokeSubmit(any(), any(), any(), any(), any(), any()) + val captor = argumentCaptor>() + verify(operatonTaskService).completeTaskWithFormData(eq("task-1"), captor.capture()) + assertThat(captor.firstValue).containsEntry("caseApproved", true) + assertThat(captor.firstValue).containsEntry("score", 5) + assertThat(result.errors).isEmpty() + assertThat(result.fieldErrors).isEmpty() + } + + @Test + fun `refuses the submission while the plugin's changed content awaits re-acceptance`() { + givenProcessLink(bundleKey = "review") + givenTask() + givenManifestWithTaskFormBundle(key = "review", submitHandler = true) + val changedDefinition = mock { + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + on { requiresReacceptance } doReturn true + } + whenever(definitionService.get(definitionId)).thenReturn(changedDefinition) + + val result = service.handleSubmission( + processLinkId, + objectMapper.readTree("""{"decision":"approve"}"""), + documentId = "doc-1", + taskInstanceId = "task-1", + ) + + assertThat(result.errors).singleElement().asString().contains("awaits re-acceptance") + // Neither the hook nor completion may run for a changed package. + verify(hostClient, never()).invokeSubmit(any(), any(), any(), any(), any(), any()) + verify(processDocumentService, never()).dispatch(any()) + verify(operatonTaskService, never()).completeTaskWithFormData(any(), any()) + } + + private fun givenProcessLink(bundleKey: String?) { + val processLink = ExternalPluginTaskFormProcessLink( + id = processLinkId, + processDefinitionId = "process-def-id", + activityId = "UserTask_Review", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configurationId, + bundleKey = bundleKey, + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + whenever( + processLinkService.getProcessLink(processLinkId, ExternalPluginTaskFormProcessLink::class.java), + ).thenReturn(processLink) + } + + private fun givenTask() { + val task = mock { + on { id } doReturn "task-1" + } + whenever(operatonTaskService.findTaskById("task-1")).thenReturn(task) + } + + private fun givenManifestWithTaskFormBundle(key: String, submitHandler: Boolean) { + val bundle = objectMapper.createObjectNode().apply { + put("type", "task-form") + put("key", key) + if (submitHandler) put("submitHandler", true) + } + val manifest: ObjectNode = objectMapper.createObjectNode().apply { + set("frontendBundles", objectMapper.createArrayNode().add(bundle)) + } + val configuration = mock { + on { this.definitionId } doReturn definitionId + } + val definition = mock { + on { manifestJson } doReturn manifest + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + } + whenever(configurationService.get(configurationId)).thenReturn(configuration) + whenever(definitionService.get(definitionId)).thenReturn(definition) + } + + private fun givenHook() { + val host = mock { + on { baseUrl } doReturn "http://localhost:8090" + } + whenever(hostService.get(hostId)).thenReturn(host) + whenever(hostService.decryptedSecret(host)).thenReturn("secret") + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandlerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandlerTest.kt new file mode 100644 index 0000000000..5947692283 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSupportedProcessLinkTypeHandlerTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.processlink + +import com.ritense.processlink.domain.ActivityTypeWithEventName +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ExternalPluginTaskFormSupportedProcessLinkTypeHandlerTest { + + private val handler = ExternalPluginTaskFormSupportedProcessLinkTypeHandler() + + @Test + fun `supports user task create as an enabled type`() { + val type = handler.getProcessLinkType(ActivityTypeWithEventName.USER_TASK_CREATE.value) + + assertThat(type).isNotNull + assertThat(type!!.processLinkType).isEqualTo("external_plugin_task_form") + assertThat(type.enabled).isTrue() + } + + @Test + fun `does not support service task start`() { + assertThat(handler.getProcessLinkType(ActivityTypeWithEventName.SERVICE_TASK_START.value)).isNull() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilterTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilterTest.kt new file mode 100644 index 0000000000..1848265e53 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginEndpointAllowlistFilterTest.kt @@ -0,0 +1,355 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import java.util.UUID + +class ExternalPluginEndpointAllowlistFilterTest { + + private val grantedEndpointRepository = mock() + private val filter = ExternalPluginEndpointAllowlistFilter(grantedEndpointRepository) + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + fun `passes through when the principal is not an external plugin service principal`() { + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken("a-regular-user", "credentials", emptyList()) + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(response.status).isEqualTo(200) + verify(grantedEndpointRepository, never()).findAllByConfigurationId(any()) + } + + @Test + fun `passes through when there is no authentication at all`() { + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(response.status).isEqualTo(200) + verify(grantedEndpointRepository, never()).findAllByConfigurationId(any()) + } + + @Test + fun `blocks access to external-plugin management endpoints`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + val request = request("GET", "/api/management/v1/external-plugin/host") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + verify(grantedEndpointRepository, never()).findAllByConfigurationId(any()) + } + + @Test + fun `allows a request that matches a granted method and pattern`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(200) + assertThat(chain.request).isNotNull() + } + + @Test + fun `blocks a request whose method is not granted`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + val request = request("POST", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `blocks a request whose path is not granted`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + val request = request("GET", "/api/v1/case/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `denies when the configuration has no granted endpoints`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)).thenReturn(emptyList()) + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `allows a granted request for an external plugin user principal`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(200) + assertThat(chain.request).isNotNull() + } + + @Test + fun `blocks an ungranted request for an external plugin user principal`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + val request = request("GET", "/api/v1/case/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `denylist blocks role management endpoints even when explicitly granted`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/management/v1/roles/**"))) + val request = request("GET", "/api/management/v1/roles") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `denylist blocks user-token minting even when a catch-all endpoint is granted`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "POST", "/**"))) + val request = request("POST", "/api/v1/external-plugin/configuration/$configId/user-token") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `denylist blocks permission management endpoints for user principals too`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "POST", "/**"))) + val request = request("POST", "/api/management/v1/permissions/search") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `user-token introspection carve-out lets a user principal through despite denylist and grants`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + // No grants at all — the carve-out must not depend on them (nor hit the repository). + val request = request("GET", "/api/v1/external-plugin/user-token/introspect") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(200) + assertThat(chain.request).isNotNull() + verify(grantedEndpointRepository, never()).findAllByConfigurationId(any()) + } + + @Test + fun `user-token introspection carve-out is GET-only`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)).thenReturn(emptyList()) + val request = request("POST", "/api/v1/external-plugin/user-token/introspect") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `service tokens get no introspection carve-out`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/**"))) + val request = request("GET", "/api/v1/external-plugin/user-token/introspect") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `other external-plugin endpoints remain denylisted for user principals`() { + val configId = UUID.randomUUID() + authenticateAsUser(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/**"))) + val request = request("GET", "/api/v1/external-plugin/menu-pages") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(response.status).isEqualTo(403) + assertThat(chain.request).isNull() + } + + @Test + fun `an invalid stored pattern is skipped instead of failing the request`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)).thenReturn( + listOf( + // Invalid embedded regex — must not produce a 500. + grantedEndpoint(configId, "GET", "/api/v1/document/{id:[}"), + grantedEndpoint(configId, "GET", "/api/v1/document/*"), + ), + ) + val request = request("GET", "/api/v1/document/123") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + // The valid grant still matches; the invalid one is ignored. + assertThat(response.status).isEqualTo(200) + assertThat(chain.request).isNotNull() + } + + @Test + fun `granted endpoints are cached per configuration within the TTL`() { + val configId = UUID.randomUUID() + authenticateAsPlugin(configId) + whenever(grantedEndpointRepository.findAllByConfigurationId(configId)) + .thenReturn(listOf(grantedEndpoint(configId, "GET", "/api/v1/document/*"))) + + repeat(3) { + val response = MockHttpServletResponse() + filter.doFilter(request("GET", "/api/v1/document/123"), response, MockFilterChain()) + assertThat(response.status).isEqualTo(200) + } + + verify(grantedEndpointRepository, org.mockito.kotlin.times(1)).findAllByConfigurationId(configId) + } + + private fun request(method: String, path: String) = + MockHttpServletRequest(method, path).apply { servletPath = path } + + private fun authenticateAsPlugin(configId: UUID) { + val principal = ExternalPluginServicePrincipal(configId, "case-summary", "0.1.0") + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, "token", emptyList()) + } + + private fun authenticateAsUser(configId: UUID) { + val principal = ExternalPluginUserPrincipal("john.doe", listOf("ROLE_USER"), configId) + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, "token", principal.authorities) + } + + private fun grantedEndpoint(configId: UUID, method: String, pattern: String) = + ExternalPluginGrantedEndpoint( + id = UUID.randomUUID(), + configurationId = configId, + httpMethod = method, + endpointPattern = pattern, + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilterTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilterTest.kt new file mode 100644 index 0000000000..beec788b77 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginServiceTokenFilterTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_CONFIG_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.PLUGIN_VERSION_CLAIM +import com.ritense.externalplugin.service.ExternalPluginServiceTokenService.Companion.TOKEN_GENERATION_CLAIM +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import jakarta.servlet.http.HttpServletRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import java.util.Optional +import java.util.UUID +import javax.crypto.SecretKey + +class ExternalPluginServiceTokenFilterTest { + + private val secret = "test-secret-test-secret-test-secret-1234" + private val keyProvider = ExternalPluginServiceTokenKeyProvider(secret) + private val configurationRepository: ExternalPluginConfigurationRepository = mock() + private val authenticator = ExternalPluginServiceTokenAuthenticator(configurationRepository) + private val filter = ExternalPluginServiceTokenFilter(keyProvider, authenticator) + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + fun `passes through and sets no principal when there is no authorization header`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `passes through when the authorization header is not a bearer token`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Basic dXNlcjpwYXNz") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `passes through a bearer token signed with a different key (e g a keycloak token)`() { + val otherKey = Keys.hmacShaKeyFor("another-secret-another-secret-1234567".toByteArray()) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(key = otherKey)}") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(SecurityContextHolder.getContext().authentication).isNull() + // The authorization header must be left intact so the downstream OAuth2 filter can handle it. + assertThat((chain.request as HttpServletRequest).getHeader("Authorization")).isNotNull() + } + + @Test + fun `passes through a token signed with our key but carrying the wrong type claim`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(type = "some-other-type")}") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(chain.request).isNotNull() + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `authenticates a valid service token and strips the authorization header downstream`() { + val configId = UUID.randomUUID() + stubConfiguration(configId, tokenGeneration = 0) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(configId = configId.toString())}") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + assertThat(authentication).isNotNull() + val principal = authentication!!.principal + assertThat(principal).isInstanceOf(ExternalPluginServicePrincipal::class.java) + principal as ExternalPluginServicePrincipal + assertThat(principal.pluginConfigId).isEqualTo(configId) + assertThat(principal.pluginId).isEqualTo("case-summary") + assertThat(principal.pluginVersion).isEqualTo("0.1.0") + + assertThat(chain.request).isNotNull() + assertThat((chain.request as HttpServletRequest).getHeader("Authorization")).isNull() + } + + @Test + fun `rejects a token minted under a previous generation (revoked)`() { + val configId = UUID.randomUUID() + stubConfiguration(configId, tokenGeneration = 2) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(configId = configId.toString(), tokenGeneration = 1)}") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `rejects a token whose configuration no longer exists`() { + whenever(configurationRepository.findById(any())).thenReturn(Optional.empty()) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token()}") + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `rejects a token without a generation claim`() { + val configId = UUID.randomUUID() + stubConfiguration(configId, tokenGeneration = 0) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader( + "Authorization", + "Bearer ${token(configId = configId.toString(), tokenGeneration = null)}" + ) + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + filter.doFilter(request, response, chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + private fun stubConfiguration(configId: UUID, tokenGeneration: Long) { + whenever(configurationRepository.findById(configId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configId, + definitionId = UUID.randomUUID(), + title = "Config", + tokenGeneration = tokenGeneration, + ) + ) + ) + } + + private fun token( + type: String = ExternalPluginServiceTokenKeyProvider.TOKEN_TYPE, + configId: String = UUID.randomUUID().toString(), + tokenGeneration: Long? = 0, + key: SecretKey = keyProvider.signingKey, + ): String = + Jwts.builder() + .claim(ExternalPluginServiceTokenKeyProvider.TYPE_CLAIM, type) + .claim(PLUGIN_CONFIG_ID_CLAIM, configId) + .claim(PLUGIN_ID_CLAIM, "case-summary") + .claim(PLUGIN_VERSION_CLAIM, "0.1.0") + .apply { if (tokenGeneration != null) claim(TOKEN_GENERATION_CLAIM, tokenGeneration) } + .signWith(key, Jwts.SIG.HS256) + .compact() +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilterTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilterTest.kt new file mode 100644 index 0000000000..1033474a79 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/security/ExternalPluginUserTokenFilterTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.security + +import com.ritense.authorization.AuthorizationContext +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.service.ExternalPluginUserTokenService.Companion.PLUGIN_CONFIG_ID_CLAIM +import com.ritense.externalplugin.service.ExternalPluginUserTokenService.Companion.ROLES_CLAIM +import com.ritense.externalplugin.service.ExternalPluginUserTokenService.Companion.TOKEN_GENERATION_CLAIM +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder +import java.util.Optional +import java.util.UUID +import javax.crypto.SecretKey + +class ExternalPluginUserTokenFilterTest { + + private val secret = "test-secret-test-secret-test-secret-1234" + private val keyProvider = ExternalPluginUserTokenKeyProvider(secret) + private val configurationRepository: ExternalPluginConfigurationRepository = mock().also { + // Every stubbed configuration is at generation 0, matching the default claim in [token]. + whenever(it.findById(any())).thenAnswer { invocation -> + Optional.of( + ExternalPluginConfiguration( + id = invocation.getArgument(0), + definitionId = UUID.randomUUID(), + title = "Config", + tokenGeneration = 0, + ) + ) + } + } + private val authenticator = ExternalPluginUserTokenAuthenticator(configurationRepository) + private val filter = ExternalPluginUserTokenFilter(keyProvider, authenticator) + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + fun `passes through when there is no authorization header`() { + val chain = MockFilterChain() + filter.doFilter(MockHttpServletRequest("GET", "/api/v1/document/1"), MockHttpServletResponse(), chain) + + assertThat(chain.request).isNotNull() + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `passes through a bearer token signed with a different key`() { + val otherKey = Keys.hmacShaKeyFor("another-secret-another-secret-1234567".toByteArray()) + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(key = otherKey)}") + val chain = MockFilterChain() + + filter.doFilter(request, MockHttpServletResponse(), chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + assertThat((chain.request as HttpServletRequest).getHeader("Authorization")).isNotNull() + } + + @Test + fun `passes through a token with the wrong type claim`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(type = "external_plugin_service")}") + val chain = MockFilterChain() + + filter.doFilter(request, MockHttpServletResponse(), chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + @Test + fun `authenticates a valid user token, rebuilds the user authorities and strips the header`() { + val configId = UUID.randomUUID() + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader( + "Authorization", + "Bearer ${token(configId = configId.toString(), roles = listOf("ROLE_USER", "ROLE_ADMIN"))}", + ) + val chain = MockFilterChain() + + filter.doFilter(request, MockHttpServletResponse(), chain) + + val authentication = SecurityContextHolder.getContext().authentication + assertThat(authentication).isNotNull() + assertThat(authentication!!.name).isEqualTo("john.doe") + assertThat(authentication.authorities.map { it.authority }) + .containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN") + val principal = authentication.principal + assertThat(principal).isInstanceOf(ExternalPluginUserPrincipal::class.java) + assertThat((principal as ExternalPluginUserPrincipal).pluginConfigId).isEqualTo(configId) + + // The Authorization header must be hidden so BearerTokenAuthenticationFilter does not re-process it. + assertThat((chain.request as HttpServletRequest).getHeader("Authorization")).isNull() + } + + @Test + fun `does not bypass authorization - PBAC stays active during the chain`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token()}") + + var ignoreAuthorizationDuringChain: Boolean? = null + val chain = FilterChain { _, _ -> + ignoreAuthorizationDuringChain = AuthorizationContext.ignoreAuthorization + } + + filter.doFilter(request, MockHttpServletResponse(), chain) + + // The service-token filter would flip this to true; the user-token filter must NOT. + assertThat(ignoreAuthorizationDuringChain).isFalse() + } + + @Test + fun `rejects a user token minted under a previous generation (revoked)`() { + val request = MockHttpServletRequest("GET", "/api/v1/document/1") + request.addHeader("Authorization", "Bearer ${token(tokenGeneration = 1)}") // config is at 0 + val chain = MockFilterChain() + + filter.doFilter(request, MockHttpServletResponse(), chain) + + assertThat(SecurityContextHolder.getContext().authentication).isNull() + } + + private fun token( + type: String = ExternalPluginUserTokenKeyProvider.TOKEN_TYPE, + configId: String = UUID.randomUUID().toString(), + roles: List = listOf("ROLE_USER"), + tokenGeneration: Long = 0, + key: SecretKey = keyProvider.signingKey, + ): String = + Jwts.builder() + .subject("john.doe") + .claim(ExternalPluginUserTokenKeyProvider.TYPE_CLAIM, type) + .claim(PLUGIN_CONFIG_ID_CLAIM, configId) + .claim(ROLES_CLAIM, roles) + .claim(TOKEN_GENERATION_CLAIM, tokenGeneration) + .signWith(key, Jwts.SIG.HS256) + .compact() +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolverTest.kt new file mode 100644 index 0000000000..eab8b4fd7a --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginBundleUrlResolverTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginBundleUrlResolverTest { + + private val objectMapper = ObjectMapper() + private val configurationRepository = mock() + private val definitionRepository = mock() + private val resolver = ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository) + + @Test + fun `resolves the sole page bundle when no key is given`() { + val configId = stub( + """[ { "type":"config", "path":"/bundles/config.html" }, + { "type":"page", "key":"overview", "path":"/bundles/page.html" } ]""" + ) + + val url = resolver.resolve(configId, "page", null) + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/page.html") + } + + @Test + fun `resolves the matching key when several bundles of a type exist`() { + val configId = stub( + """[ { "type":"task-form", "key":"approval", "path":"/bundles/approval.html" }, + { "type":"task-form", "key":"review", "path":"/bundles/review.html" } ]""" + ) + + val url = resolver.resolve(configId, "task-form", "review") + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/review.html") + } + + @Test + fun `returns null when no bundle of the requested type exists`() { + val configId = stub("""[ { "type":"case-tab", "path":"/bundles/case-tab.html" } ]""") + + assertThat(resolver.resolve(configId, "page", null)).isNull() + } + + @Test + fun `returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolve(configId, "page", null)).isNull() + } + + private fun stub(bundlesJson: String): UUID { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "test", + ) + val manifest = objectMapper.createObjectNode() + .set("frontendBundles", objectMapper.readTree(bundlesJson)) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://host:8090/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifest, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + return configId + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt new file mode 100644 index 0000000000..1512904263 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginCaseTabResolverImplTest { + + private val objectMapper = ObjectMapper() + private val configurationRepository = mock() + private val definitionRepository = mock() + private val resolver = ExternalPluginCaseTabResolverImpl( + ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository), + configurationRepository, + definitionRepository, + ) + + @Test + fun `resolves the bundle url for the sole case-tab bundle when no key is given`() { + val (configId, definitionId) = stub( + """[ { "type":"config", "path":"/bundles/config.html" }, + { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, null) + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/case-tab.html") + } + + @Test + fun `resolves the bundle url for the matching key when multiple case-tab bundles exist`() { + val (configId, _) = stub( + """[ { "type":"case-tab", "key":"summary", "path":"/bundles/summary.html" }, + { "type":"case-tab", "key":"details", "path":"/bundles/details.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, "details") + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/details.html") + } + + @Test + fun `returns null when there is no case-tab bundle`() { + val (configId, _) = stub("""[ { "type":"config", "path":"/bundles/config.html" } ]""") + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `resolvePluginDefinition returns the plugin id and version of the configuration's definition`() { + val (configId, _) = stub("""[ { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""") + + val definition = resolver.resolvePluginDefinition(configId) + + assertThat(definition).isNotNull + assertThat(definition!!.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(definition.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `resolvePluginDefinition returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolvePluginDefinition(configId)).isNull() + } + + private fun stub(bundlesJson: String): Pair { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "test", + ) + val manifest = objectMapper.createObjectNode() + .set("frontendBundles", objectMapper.readTree(bundlesJson)) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://host:8090/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifest, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + return configId to definitionId + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt new file mode 100644 index 0000000000..f1627e395f --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginCaseWidgetResolverImplTest { + + private val objectMapper = ObjectMapper() + private val configurationRepository = mock() + private val definitionRepository = mock() + private val resolver = ExternalPluginCaseWidgetResolverImpl( + ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository), + configurationRepository, + definitionRepository, + ) + + @Test + fun `resolves the bundle url for the sole case-widget bundle when no key is given`() { + val (configId, _) = stub( + """[ { "type":"config", "path":"/bundles/config.html" }, + { "type":"case-widget", "key":"summary-widget", "path":"/bundles/case-widget.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, null) + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/case-widget.html") + } + + @Test + fun `resolves the bundle url for the matching key when multiple case-widget bundles exist`() { + val (configId, _) = stub( + """[ { "type":"case-widget", "key":"summary-widget", "path":"/bundles/summary.html" }, + { "type":"case-widget", "key":"details-widget", "path":"/bundles/details.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, "details-widget") + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/details.html") + } + + @Test + fun `returns null when there is no case-widget bundle`() { + val (configId, _) = stub("""[ { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""") + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `resolvePluginDefinition returns the plugin id and version of the configuration's definition`() { + val (configId, _) = stub("""[ { "type":"case-widget", "key":"summary-widget", "path":"/bundles/case-widget.html" } ]""") + + val definition = resolver.resolvePluginDefinition(configId) + + assertThat(definition).isNotNull + assertThat(definition!!.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(definition.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `resolvePluginDefinition returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolvePluginDefinition(configId)).isNull() + } + + private fun stub(bundlesJson: String): Pair { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "test", + ) + val manifest = objectMapper.createObjectNode() + .set("frontendBundles", objectMapper.readTree(bundlesJson)) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://host:8090/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifest, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + return configId to definitionId + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt new file mode 100644 index 0000000000..305020ca8c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt @@ -0,0 +1,479 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.service.CaseExternalPluginWidgetRef +import com.ritense.case_.service.CaseExternalPluginWidgetService +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinition +import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinitionId +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto.Companion.SOURCE_EXTERNAL +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito.lenient +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.jpa.domain.Specification +import java.util.Optional +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class ExternalPluginConfigurationMappingResolverTest { + + @Mock + lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + + @Mock + lateinit var taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository + + @Mock + lateinit var configurationRepository: ExternalPluginConfigurationRepository + + @Mock + lateinit var caseExternalPluginTabRepository: CaseExternalPluginTabRepository + + @Mock + lateinit var caseTabRepository: CaseTabRepository + + @Mock + lateinit var caseExternalPluginWidgetService: CaseExternalPluginWidgetService + + @Mock + lateinit var processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService + + @Mock + lateinit var caseDefinitionChecker: CaseDefinitionChecker + + @Mock + lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var resolver: ExternalPluginConfigurationMappingResolver + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @BeforeEach + fun before() { + resolver = ExternalPluginConfigurationMappingResolver( + processLinkRepository, + taskFormProcessLinkRepository, + configurationRepository, + caseExternalPluginTabRepository, + caseTabRepository, + caseExternalPluginWidgetService, + processDefinitionCaseDefinitionService, + caseDefinitionChecker, + applicationEventPublisher, + ) + lenient().whenever(taskFormProcessLinkRepository.findByProcessDefinitionId(any())).thenReturn(emptyList()) + lenient().whenever(caseTabRepository.findAll(any>())).thenReturn(emptyList()) + lenient().whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(any())).thenReturn(emptyList()) + } + + @Test + fun `resolve asserts user can update case definition configuration`() { + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + + resolver.resolve(caseDefinitionId, emptyMap()) + + verify(caseDefinitionChecker).assertCanUpdateCaseDefinitionConfiguration( + caseDefinitionId, + listOf( + ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.TASK_FORM_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.CASE_TAB_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.CASE_WIDGET_ISSUE_TYPE, + ), + ) + } + + @Test + fun `resolve replaces externalPluginConfigurationId based on source UUID mapping`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = processLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val captor = argumentCaptor() + verify(processLinkRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(targetId) + } + + @Test + fun `resolve replaces the configuration id on task-form links`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = taskFormLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(taskFormProcessLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val captor = argumentCaptor() + verify(taskFormProcessLinkRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(targetId) + } + + @Test + fun `resolve remaps a dangling case tab contentKey and creates the side row`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "$sourceId:bundle-key", + ) + stubProcessDefinitions() + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val tabCaptor = argumentCaptor() + verify(caseTabRepository).save(tabCaptor.capture()) + assertThat(tabCaptor.firstValue.contentKey).isEqualTo("$targetId:bundle-key") + + // No prior side row is stubbed, so the preserved plugin identity is null here. + verify(caseExternalPluginTabRepository).save( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = targetId, + bundleKey = "bundle-key", + ) + ) + } + + @Test + fun `resolve does not save links that are not in the mapping`() { + val link = processLink(externalPluginConfigurationId = UUID.randomUUID()) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + resolver.resolve(caseDefinitionId, mapOf(UUID.randomUUID() to UUID.randomUUID())) + + verify(processLinkRepository, never()).save(any()) + } + + @Test + fun `resolve emits resolved event when no dangling links remain`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = processLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + // Each surface is judged independently now; the process-link surface is clean. + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `resolve emits detected event for the process-link surface when a dangling link remains`() { + val danglingLink = processLink(externalPluginConfigurationId = UUID.randomUUID()) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(danglingLink)) + whenever(configurationRepository.existsById(any())).thenReturn(false) + + resolver.resolve(caseDefinitionId, emptyMap()) + + // The dangling service-task link raises the process-link issue; the clean task-form and tab + // surfaces are resolved independently (no cross-surface clobber). + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.TASK_FORM_ISSUE_TYPE) + ) + } + + @Test + fun `getDanglingPluginConfigurations groups links by plugin definition key and version, tagged external`() { + val danglingId1 = UUID.randomUUID() + val danglingId2 = UUID.randomUUID() + val existingId = UUID.randomUUID() + + val link1 = processLink(externalPluginConfigurationId = danglingId1, pluginDefinitionKey = "case-summary") + val link2 = processLink(externalPluginConfigurationId = danglingId2, pluginDefinitionKey = "case-summary") + val link3 = processLink(externalPluginConfigurationId = existingId, pluginDefinitionKey = "other-plugin") + + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link1, link2, link3)) + whenever(configurationRepository.existsById(any())).thenAnswer { invocation -> + invocation.arguments[0] == existingId + } + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.sourcePluginConfigurationIds).containsExactlyInAnyOrder(danglingId1, danglingId2) + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `getDanglingPluginConfigurations skips BUILDING_BLOCK links`() { + val link = processLink( + externalPluginConfigurationId = null, + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).isEmpty() + } + + @Test + fun `getDanglingPluginConfigurations includes dangling EXTERNAL_PLUGIN case tabs`() { + val danglingConfigId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = danglingConfigId.toString(), + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn( + Optional.of( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = danglingConfigId, + bundleKey = null, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + ) + ) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + assertThat(result.single().sourcePluginConfigurationIds).containsExactly(danglingConfigId) + assertThat(result.single().source).isEqualTo(SOURCE_EXTERNAL) + // The persisted plugin identity now makes the dangling tab identifiable (not key-less). + assertThat(result.single().pluginDefinitionKey).isEqualTo("case-summary") + assertThat(result.single().pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `resolve remaps external-plugin widgets through the widget service`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + stubProcessDefinitions() + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + verify(caseExternalPluginWidgetService).remapConfiguration(caseDefinitionId, mapOf(sourceId to targetId)) + } + + @Test + fun `getDanglingPluginConfigurations includes dangling external-plugin widgets grouped by plugin identity`() { + val danglingConfigId1 = UUID.randomUUID() + val danglingConfigId2 = UUID.randomUUID() + val existingConfigId = UUID.randomUUID() + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId)).thenReturn( + listOf( + widgetRef(danglingConfigId1, "case-summary", "0.1.0"), + widgetRef(danglingConfigId2, "case-summary", "0.1.0"), + widgetRef(existingConfigId, "other-plugin", "1.0.0"), + ) + ) + whenever(configurationRepository.existsById(any())).thenAnswer { it.arguments[0] == existingConfigId } + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.sourcePluginConfigurationIds).containsExactlyInAnyOrder(danglingConfigId1, danglingConfigId2) + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `recheckIssuesForCaseDefinition emits detected event for the case-widget surface when a widget is dangling`() { + val danglingConfigId = UUID.randomUUID() + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId)).thenReturn( + listOf(widgetRef(danglingConfigId, "case-summary", "0.1.0")) + ) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + resolver.recheckIssuesForCaseDefinition(caseDefinitionId) + + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.CASE_WIDGET_ISSUE_TYPE) + ) + } + + @Test + fun `recheckIssuesForProcessDefinition emits resolved event when all links are valid`() { + val pdId = ProcessDefinitionId.of("pd-1") + val link = processDefinitionCaseDefinition("pd-1") + whenever(processDefinitionCaseDefinitionService.findByProcessDefinitionIdOrNull(eq(pdId))).thenReturn(link) + whenever(processDefinitionCaseDefinitionService.findProcessDefinitionCaseDefinitions(eq(caseDefinitionId))) + .thenReturn(listOf(link)) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + + resolver.recheckIssuesForProcessDefinition("pd-1") + + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + } + + @Test + fun `recheckIssuesForCaseDefinition emits detected event for the case-tab surface when a tab is dangling`() { + val danglingConfigId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "$danglingConfigId:summary", + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn( + Optional.of( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = danglingConfigId, + bundleKey = "summary", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + ) + ) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + resolver.recheckIssuesForCaseDefinition(caseDefinitionId) + + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.CASE_TAB_ISSUE_TYPE) + ) + } + + private fun stubProcessDefinitions(vararg processDefinitionIds: String) { + val links = processDefinitionIds.map { processDefinitionCaseDefinition(it) } + whenever(processDefinitionCaseDefinitionService.findProcessDefinitionCaseDefinitions(eq(caseDefinitionId))) + .thenReturn(links) + } + + private fun widgetRef( + configurationId: UUID?, + pluginDefinitionKey: String?, + pluginDefinitionVersion: String?, + ) = CaseExternalPluginWidgetRef( + caseDefinitionId = caseDefinitionId, + tabKey = "summary", + widgetKey = "summary-widget", + configurationId = configurationId, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + + private fun processDefinitionCaseDefinition(processDefinitionId: String) = + ProcessDefinitionCaseDefinition( + id = ProcessDefinitionCaseDefinitionId( + processDefinitionId = ProcessDefinitionId.of(processDefinitionId), + caseDefinitionId = caseDefinitionId, + ), + ) + + private fun processLink( + externalPluginConfigurationId: UUID?, + referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey: String? = "case-summary", + ): ExternalPluginProcessLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "Task_1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = externalPluginConfigurationId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = referenceType, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = if (referenceType == PluginConfigurationReferenceType.BUILDING_BLOCK) "1.0.0" else null, + ), + ) + + private fun taskFormLink( + externalPluginConfigurationId: UUID, + ): ExternalPluginTaskFormProcessLink = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "Task_1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = externalPluginConfigurationId, + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceDeleteTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceDeleteTest.kt new file mode 100644 index 0000000000..4739696e6b --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceDeleteTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.exception.ExternalPluginConfigurationInUseException +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginConfigurationServiceDeleteTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var grantedEndpointRepository: ExternalPluginGrantedEndpointRepository + private lateinit var grantedEventRepository: ExternalPluginGrantedEventRepository + private lateinit var hostClient: ExternalPluginHostClient + private lateinit var encryptionService: EncryptionService + private lateinit var hostUsageResolver: ExternalPluginHostUsageResolver + private lateinit var service: ExternalPluginConfigurationService + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + hostRepository = mock() + grantedEndpointRepository = mock() + grantedEventRepository = mock() + hostClient = mock() + encryptionService = mock() + hostUsageResolver = mock() + service = ExternalPluginConfigurationService( + configurationRepository, + definitionRepository, + hostRepository, + grantedEndpointRepository, + grantedEventRepository, + mock(), + hostClient, + mock(), + encryptionService, + ObjectMapper(), + mock(), + hostUsageResolver, + "valtimo-events", + "http://localhost:8080", + ) + } + + @Test + fun `delete throws when usages exist and does not touch any repository or remote host`() { + val configId = UUID.randomUUID() + val configuration = configuration(configId) + val usages = listOf(usageDto(configId)) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(hostUsageResolver.findUsagesForConfiguration(configId)).thenReturn(usages) + + assertThatThrownBy { service.delete(configId) } + .isInstanceOf(ExternalPluginConfigurationInUseException::class.java) + .satisfies({ thrown -> + val problem = thrown as ExternalPluginConfigurationInUseException + assertThat(problem.parameters["configurationId"]).isEqualTo(configId.toString()) + @Suppress("UNCHECKED_CAST") + val payloadUsages = problem.parameters["usages"] as Collection + assertThat(payloadUsages).hasSize(1) + }) + + verify(grantedEndpointRepository, never()).deleteAllByConfigurationId(any()) + verify(grantedEventRepository, never()).deleteAllByConfigurationId(any()) + verify(configurationRepository, never()).delete(any()) + verify(hostClient, never()).deleteConfiguration(any(), any(), any()) + } + + @Test + fun `delete proceeds when no usages exist`() { + val configId = UUID.randomUUID() + val configuration = configuration(configId) + val definition = definition(configuration.definitionId) + val host = host(definition.hostId) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(hostUsageResolver.findUsagesForConfiguration(configId)).thenReturn(emptyList()) + whenever(definitionRepository.findById(configuration.definitionId)).thenReturn(Optional.of(definition)) + whenever(hostRepository.findById(definition.hostId)).thenReturn(Optional.of(host)) + whenever(encryptionService.decrypt(any())).thenReturn("admin-token") + + service.delete(configId) + + verify(grantedEndpointRepository).deleteAllByConfigurationId(configId) + verify(grantedEventRepository).deleteAllByConfigurationId(configId) + verify(configurationRepository).delete(configuration) + verify(hostClient).deleteConfiguration(host.baseUrl, "admin-token", configId.toString()) + } + + @Test + fun `findUsages delegates to the resolver`() { + val configId = UUID.randomUUID() + val expected = listOf(usageDto(configId)) + whenever(hostUsageResolver.findUsagesForConfiguration(configId)).thenReturn(expected) + + val result = service.findUsages(configId) + + assertThat(result).isSameAs(expected) + verify(configurationRepository, never()).delete(any()) + } + + private fun configuration(id: UUID): ExternalPluginConfiguration = ExternalPluginConfiguration( + id = id, + definitionId = UUID.randomUUID(), + title = "Primary CRM", + ) + + private fun definition(id: UUID): ExternalPluginDefinition = ExternalPluginDefinition( + id = id, + pluginId = "test-plugin", + version = "1.0.0", + hostId = UUID.randomUUID(), + baseUrl = "https://host.example", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + + private fun host(id: UUID): ExternalPluginHost = ExternalPluginHost( + id = id, + name = "remote", + baseUrl = "https://host.example", + secret = "encrypted", + status = ExternalPluginHostStatus.UNREACHABLE, + gzacCallbackBaseUrl = "http://localhost:8080", + eventBrokerAmqpUrl = null, + eventBrokerExchange = null, + ) + + private fun usageDto(configurationId: UUID): PluginUsageDto = PluginUsageDto( + configurationId = configurationId, + configurationTitle = "Primary CRM", + parentType = PluginUsageParentType.CASE, + parentKey = "complaint", + parentVersionTag = "1.0.0", + processDefinitionId = "complaint-intake:3:abc", + processDefinitionKey = "complaint-intake", + processDefinitionName = "Complaint intake", + activityId = "SendLetter", + activityName = "Send letter to citizen", + processLinkId = UUID.randomUUID(), + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServicePushTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServicePushTest.kt new file mode 100644 index 0000000000..3bee61226b --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServicePushTest.kt @@ -0,0 +1,259 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.exception.ExternalPluginNotFoundException +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedCapabilityRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.plugin.service.EncryptionService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +/** + * Guards [ExternalPluginConfigurationService.pushToHost]'s security behaviour — the content-hash + * binding and the re-acceptance freeze — and the [ExternalPluginConfigurationService.revokeTokens] + * generation bump. + */ +class ExternalPluginConfigurationServicePushTest { + + private val objectMapper = ObjectMapper() + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var grantedEndpointRepository: ExternalPluginGrantedEndpointRepository + private lateinit var grantedEventRepository: ExternalPluginGrantedEventRepository + private lateinit var grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository + private lateinit var hostClient: ExternalPluginHostClient + private lateinit var encryptionService: EncryptionService + private lateinit var propertyEncryptor: PluginPropertyEncryptor + private lateinit var serviceTokenService: ExternalPluginServiceTokenService + private lateinit var service: ExternalPluginConfigurationService + + private val definition = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "case-summary", + version = "1.0.0", + hostId = UUID.randomUUID(), + baseUrl = "https://plugin-host.example.com/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + contentHash = "sha256:accepted", + ) + + private val configuration = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definition.id, + title = "Primary", + ) + + private val host = ExternalPluginHost( + id = definition.hostId, + name = "host", + baseUrl = "https://plugin-host.example.com", + secret = "encrypted-secret", + status = ExternalPluginHostStatus.CONNECTED, + ) + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + hostRepository = mock() + grantedEndpointRepository = mock() + grantedEventRepository = mock() + grantedCapabilityRepository = mock() + hostClient = mock() + encryptionService = mock() + propertyEncryptor = mock() + serviceTokenService = mock() + whenever(configurationRepository.save(any())).thenAnswer { it.getArgument(0) } + whenever(configurationRepository.findById(configuration.id)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definition.id)).thenReturn(Optional.of(definition)) + whenever(hostRepository.findById(host.id)).thenReturn(Optional.of(host)) + whenever(encryptionService.decrypt("encrypted-secret")).thenReturn("admin-token") + whenever(propertyEncryptor.decryptSecretFields(any(), anyOrNull())).thenAnswer { it.getArgument(0) } + whenever(serviceTokenService.issue(any(), any())).thenReturn("svc-token") + service = ExternalPluginConfigurationService( + configurationRepository, + definitionRepository, + hostRepository, + grantedEndpointRepository, + grantedEventRepository, + grantedCapabilityRepository, + hostClient, + propertyEncryptor, + encryptionService, + objectMapper, + serviceTokenService, + mock(), + "gzac.events", + "http://localhost:8080", + ) + } + + @Test + fun `pushToHost sends the pinned content hash so the host can refuse a changed package`() { + service.pushToHost(configuration, definition, host) + + verify(hostClient).pushConfiguration( + baseUrl = eq("https://plugin-host.example.com"), + adminToken = eq("admin-token"), + configId = eq(configuration.id.toString()), + pluginId = eq("case-summary"), + pluginVersion = eq("1.0.0"), + properties = any(), + serviceToken = eq("svc-token"), + gzacBaseUrl = any(), + expectedContentHash = eq("sha256:accepted"), + eventSubscriptions = any(), + grantedCapabilities = any(), + grantedEndpoints = any(), + eventBrokerUrl = anyOrNull(), + eventBrokerExchange = any(), + eventBrokerExchangeType = any(), + eventQueueMode = any(), + eventQueueTtlMs = anyOrNull(), + ) + } + + @Test + fun `pushToHost refuses while the definition's changed content awaits re-acceptance`() { + definition.pendingContentHash = "sha256:changed" + + val pushed = service.pushToHost(configuration, definition, host) + + assertThat(pushed).isFalse() + // Nothing reaches the host — in particular no fresh service token is minted or shipped. + verifyNoInteractions(hostClient) + verifyNoInteractions(serviceTokenService) + } + + @Test + fun `revokeTokens bumps the generation and immediately re-pushes a fresh token`() { + val revoked = service.revokeTokens(configuration.id) + + assertThat(revoked.tokenGeneration).isEqualTo(1L) + verify(configurationRepository).save(configuration) + // The after-commit push runs immediately outside a transaction: the host receives a token + // of the *new* generation, so only leaked/hoarded tokens die. + verify(serviceTokenService).issue(eq(configuration), eq(definition)) + verify(hostClient).pushConfiguration( + baseUrl = any(), + adminToken = any(), + configId = eq(configuration.id.toString()), + pluginId = any(), + pluginVersion = any(), + properties = any(), + serviceToken = eq("svc-token"), + gzacBaseUrl = any(), + expectedContentHash = anyOrNull(), + eventSubscriptions = any(), + grantedCapabilities = any(), + grantedEndpoints = any(), + eventBrokerUrl = anyOrNull(), + eventBrokerExchange = any(), + eventBrokerExchangeType = any(), + eventQueueMode = any(), + eventQueueTtlMs = anyOrNull(), + ) + } + + @Test + fun `revokeTokens fails clearly for an unknown configuration`() { + val unknownId = UUID.randomUUID() + whenever(configurationRepository.findById(unknownId)).thenReturn(Optional.empty()) + + assertThatThrownBy { service.revokeTokens(unknownId) } + .isInstanceOf(ExternalPluginNotFoundException::class.java) + verify(configurationRepository, never()).save(any()) + } + + @Test + fun `applyApprovedOverwrite pins the new hash and re-grants every configuration to the new manifest`() { + definition.pendingContentHash = "sha256:stale-flag" + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(definition) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + val manifest = objectMapper.readTree( + """ + { + "eventSubscriptions": ["com.ritense.valtimo.document.created"], + "permissions": { + "endpoints": [{"method": "get", "pattern": "/api/v1/document/*"}], + "capabilities": ["gzac_api", "not-a-real-capability"] + } + } + """.trimIndent() + ) + + service.applyApprovedOverwrite("case-summary", "1.0.0", "sha256:new", manifest) + + assertThat(definition.contentHash).isEqualTo("sha256:new") + assertThat(definition.pendingContentHash).isNull() + verify(definitionRepository).save(definition) + + // Old grants are replaced by exactly the new declared sets. + verify(grantedEndpointRepository).deleteAllByConfigurationId(configuration.id) + verify(grantedEventRepository).deleteAllByConfigurationId(configuration.id) + verify(grantedCapabilityRepository).deleteAllByConfigurationId(configuration.id) + val endpointCaptor = argumentCaptor() + verify(grantedEndpointRepository).save(endpointCaptor.capture()) + assertThat(endpointCaptor.firstValue.httpMethod).isEqualTo("GET") + assertThat(endpointCaptor.firstValue.endpointPattern).isEqualTo("/api/v1/document/*") + val eventCaptor = argumentCaptor() + verify(grantedEventRepository).save(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.eventType).isEqualTo("com.ritense.valtimo.document.created") + // The unknown capability is skipped instead of failing after the host already replaced + // the package. + val capabilityCaptor = argumentCaptor() + verify(grantedCapabilityRepository).save(capabilityCaptor.capture()) + assertThat(capabilityCaptor.firstValue.capability.value).isEqualTo("gzac_api") + } + + @Test + fun `applyApprovedOverwrite is a no-op for a definition GZAC never discovered`() { + whenever(definitionRepository.findByPluginIdAndVersion("unknown", "9.9.9")).thenReturn(null) + + service.applyApprovedOverwrite("unknown", "9.9.9", "sha256:new", objectMapper.createObjectNode()) + + verify(definitionRepository, never()).save(any()) + verify(grantedEndpointRepository, never()).deleteAllByConfigurationId(any()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceTest.kt new file mode 100644 index 0000000000..ef0eeb9d75 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationServiceTest.kt @@ -0,0 +1,384 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginCapability +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginGrantedCapability +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedCapabilityRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.externalplugin.web.rest.dto.GrantedEndpointEntry +import com.ritense.externalplugin.web.rest.dto.GrantedEventEntry +import com.ritense.plugin.service.EncryptionService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +/** + * Guards the capability grant validation in [ExternalPluginConfigurationService.create]: only the + * capabilities known to the platform ([ExternalPluginCapability]) can be granted, and every + * capability the manifest declares must be granted. Free-form strings never reach the database. + */ +class ExternalPluginConfigurationServiceTest { + + private val objectMapper = ObjectMapper() + private val definitionId = UUID.randomUUID() + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var grantedEndpointRepository: ExternalPluginGrantedEndpointRepository + private lateinit var grantedCapabilityRepository: ExternalPluginGrantedCapabilityRepository + private lateinit var encryptionService: EncryptionService + private lateinit var propertyEncryptor: PluginPropertyEncryptor + private lateinit var service: ExternalPluginConfigurationService + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + hostRepository = mock() + grantedEndpointRepository = mock() + grantedCapabilityRepository = mock() + encryptionService = mock() + propertyEncryptor = mock() + whenever(configurationRepository.save(any())).thenAnswer { it.getArgument(0) } + whenever(propertyEncryptor.encryptSecretFields(any(), anyOrNull())).thenAnswer { it.getArgument(0) } + whenever(hostRepository.findById(any())).thenReturn(Optional.empty()) + service = ExternalPluginConfigurationService( + configurationRepository, + definitionRepository, + hostRepository, + grantedEndpointRepository, + mock(), + grantedCapabilityRepository, + mock(), + propertyEncryptor, + encryptionService, + objectMapper, + mock(), + mock(), + "gzac.events", + "http://localhost:8080", + ) + } + + @Test + fun `create rejects unknown granted capability name`() { + stubDefinition(manifestJson = null) + + assertThatThrownBy { create(grantedCapabilities = listOf("filesystem")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Unknown capability 'filesystem'") + .hasMessageContaining("gzac_api, http_request, kv, log, frontend_data") + + verify(configurationRepository, never()).save(any()) + verify(grantedCapabilityRepository, never()).save(any()) + } + + @Test + fun `create rejects when a manifest-declared capability is not granted`() { + stubDefinition(manifestJson = manifestWithCapabilities("gzac_api", "kv")) + + assertThatThrownBy { create(grantedCapabilities = listOf("gzac_api")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("All capabilities declared in the plugin manifest must be granted") + .hasMessageContaining("kv") + + verify(configurationRepository, never()).save(any()) + } + + @Test + fun `create persists granted capabilities as typed values`() { + stubDefinition(manifestJson = manifestWithCapabilities("http_request", "log")) + + val saved = create(grantedCapabilities = listOf("http_request", "log")) + + val captor = argumentCaptor() + verify(grantedCapabilityRepository, times(2)).save(captor.capture()) + assertThat(captor.allValues).allSatisfy { assertThat(it.configurationId).isEqualTo(saved.id) } + assertThat(captor.allValues.map { it.capability }).containsExactlyInAnyOrder( + ExternalPluginCapability.HTTP_REQUEST, + ExternalPluginCapability.LOG, + ) + } + + @Test + fun `create persists granted endpoints when they cover the manifest`() { + stubDefinition(manifestJson = manifestWithEndpoints("GET" to "/api/v1/document/*")) + + val saved = service.create( + definitionId = definitionId, + title = "Covered", + properties = objectMapper.createObjectNode(), + grantedEndpoints = listOf(GrantedEndpointEntry("GET", "/api/v1/document/*")), + grantedEvents = emptyList(), + ) + + val captor = argumentCaptor() + verify(grantedEndpointRepository).save(captor.capture()) + assertThat(captor.firstValue.configurationId).isEqualTo(saved.id) + assertThat(captor.firstValue.endpointPattern).isEqualTo("/api/v1/document/*") + } + + @Test + fun `create requires every manifest-declared endpoint to be granted`() { + stubDefinition( + manifestJson = manifestWithEndpoints( + "GET" to "/api/v1/document/*", + "POST" to "/api/v1/case/*", + ), + ) + + assertThatThrownBy { + service.create( + definitionId = definitionId, + title = "Partial", + properties = objectMapper.createObjectNode(), + grantedEndpoints = listOf(GrantedEndpointEntry("GET", "/api/v1/document/*")), + grantedEvents = emptyList(), + ) + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("All endpoints declared in the plugin manifest must be granted") + .hasMessageContaining("POST:/api/v1/case/*") + + verify(configurationRepository, never()).save(any()) + verify(grantedEndpointRepository, never()).save(any()) + } + + @Test + fun `create rejects a granted endpoint the manifest does not declare`() { + stubDefinition(manifestJson = manifestWithEndpoints("GET" to "/api/v1/document/*")) + + assertThatThrownBy { + service.create( + definitionId = definitionId, + title = "Overreach", + properties = objectMapper.createObjectNode(), + grantedEndpoints = listOf( + GrantedEndpointEntry("GET", "/api/v1/document/*"), + GrantedEndpointEntry("DELETE", "/api/v1/case/*"), + ), + grantedEvents = emptyList(), + ) + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Granted endpoints must be declared in the plugin manifest") + .hasMessageContaining("DELETE:/api/v1/case/*") + + verify(configurationRepository, never()).save(any()) + verify(grantedEndpointRepository, never()).save(any()) + } + + @Test + fun `create rejects a granted event subscription the manifest does not declare`() { + stubDefinition(manifestJson = manifestWithEvents("case.created")) + + assertThatThrownBy { + service.create( + definitionId = definitionId, + title = "Extra event", + properties = objectMapper.createObjectNode(), + grantedEndpoints = emptyList(), + grantedEvents = listOf(GrantedEventEntry("case.created"), GrantedEventEntry("case.deleted")), + ) + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Granted event subscriptions must be declared in the plugin manifest") + .hasMessageContaining("case.deleted") + + verify(configurationRepository, never()).save(any()) + } + + @Test + fun `create rejects a granted capability the manifest does not declare`() { + stubDefinition(manifestJson = manifestWithCapabilities("gzac_api")) + + assertThatThrownBy { create(grantedCapabilities = listOf("gzac_api", "kv")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Granted capabilities must be declared in the plugin manifest") + .hasMessageContaining("kv") + + verify(configurationRepository, never()).save(any()) + verify(grantedCapabilityRepository, never()).save(any()) + } + + @Test + fun `update keeps the stored ciphertext when a secret property is omitted from the payload`() { + val secretAwareService = serviceWithRealEncryptor() + val configId = UUID.randomUUID() + val storedProperties = objectMapper.createObjectNode() + .put("url", "https://old.example.com") + .put("apiKey", "stored-ciphertext") + val config = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + properties = storedProperties, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(config)) + stubDefinition(manifestJson = null, configSchema = secretSchema()) + whenever(encryptionService.decrypt("stored-ciphertext")).thenReturn("plain-secret") + + // The browser round-trips the masked GET response: the secret field is absent. + val incoming = objectMapper.createObjectNode().put("url", "https://new.example.com") + val saved = secretAwareService.update(configId, "Config", incoming) + + assertThat(saved.properties?.get("apiKey")?.asText()).isEqualTo("stored-ciphertext") + assertThat(saved.properties?.get("url")?.asText()).isEqualTo("https://new.example.com") + // The placeholder was never (re-)encrypted. + verify(encryptionService, never()).encrypt(any()) + } + + @Test + fun `update encrypts a newly supplied secret value`() { + val secretAwareService = serviceWithRealEncryptor() + val configId = UUID.randomUUID() + val config = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + properties = objectMapper.createObjectNode().put("apiKey", "stored-ciphertext"), + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(config)) + stubDefinition(manifestJson = null, configSchema = secretSchema()) + whenever(encryptionService.encrypt("new-secret")).thenReturn("new-ciphertext") + + val incoming = objectMapper.createObjectNode().put("apiKey", "new-secret") + val saved = secretAwareService.update(configId, "Config", incoming) + + assertThat(saved.properties?.get("apiKey")?.asText()).isEqualTo("new-ciphertext") + } + + @Test + fun `maskedProperties omits x-secret fields entirely`() { + val secretAwareService = serviceWithRealEncryptor() + stubDefinition(manifestJson = null, configSchema = secretSchema()) + val config = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definitionId, + title = "Config", + properties = objectMapper.createObjectNode() + .put("url", "https://example.com") + .put("apiKey", "stored-ciphertext"), + ) + + val masked = secretAwareService.maskedProperties(config) + + assertThat(masked.has("apiKey")).isFalse() + assertThat(masked.get("url").asText()).isEqualTo("https://example.com") + // Never decrypted for the read model. + verify(encryptionService, never()).decrypt(any()) + } + + private fun serviceWithRealEncryptor(): ExternalPluginConfigurationService = ExternalPluginConfigurationService( + configurationRepository, + definitionRepository, + hostRepository, + grantedEndpointRepository, + mock(), + grantedCapabilityRepository, + mock(), + PluginPropertyEncryptor(encryptionService), + encryptionService, + objectMapper, + mock(), + mock(), + "gzac.events", + "http://localhost:8080", + ) + + private fun secretSchema(): ObjectNode = objectMapper.readTree( + """ + { + "type": "object", + "properties": { + "url": {"type": "string"}, + "apiKey": {"type": "string", "x-secret": true} + } + } + """.trimIndent(), + ) as ObjectNode + + private fun manifestWithEndpoints(vararg endpoints: Pair): ObjectNode = + objectMapper.createObjectNode().apply { + putObject("permissions").putArray("endpoints").apply { + endpoints.forEach { (method, pattern) -> + addObject().put("method", method).put("pattern", pattern) + } + } + } + + private fun create(grantedCapabilities: List): ExternalPluginConfiguration = service.create( + definitionId = definitionId, + title = "Test configuration", + properties = objectMapper.createObjectNode(), + grantedEndpoints = emptyList(), + grantedEvents = emptyList(), + grantedCapabilities = grantedCapabilities, + ) + + private fun stubDefinition(manifestJson: ObjectNode?, configSchema: ObjectNode? = null) { + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "test-plugin", + version = "1.0.0", + hostId = UUID.randomUUID(), + baseUrl = "https://plugin-host.example.com", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestJson, + configSchema = configSchema, + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + } + + private fun manifestWithCapabilities(vararg capabilities: String): ObjectNode = + objectMapper.createObjectNode().apply { + putObject("permissions").putArray("capabilities").apply { + capabilities.forEach { add(it) } + } + } + + private fun manifestWithEvents(vararg eventTypes: String): ObjectNode = + objectMapper.createObjectNode().apply { + putArray("eventSubscriptions").apply { + eventTypes.forEach { add(it) } + } + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionServiceTest.kt new file mode 100644 index 0000000000..d9df80da48 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDefinitionServiceTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginDefinitionServiceTest { + + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var service: ExternalPluginDefinitionService + + private val definition = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "case-summary", + version = "1.0.0", + hostId = UUID.randomUUID(), + baseUrl = "https://plugin-host.example.com/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + contentHash = "sha256:accepted", + pendingContentHash = "sha256:changed", + ) + + @BeforeEach + fun setUp() { + definitionRepository = mock() + whenever(definitionRepository.findById(definition.id)).thenReturn(Optional.of(definition)) + whenever(definitionRepository.save(any())).thenAnswer { it.getArgument(0) } + service = ExternalPluginDefinitionService(definitionRepository) + } + + @Test + fun `acceptContent re-pins the pending hash and clears the flag`() { + val accepted = service.acceptContent(definition.id, "sha256:changed") + + assertThat(accepted.contentHash).isEqualTo("sha256:changed") + assertThat(accepted.pendingContentHash).isNull() + assertThat(accepted.requiresReacceptance).isFalse() + verify(definitionRepository).save(definition) + } + + @Test + fun `acceptContent rejects a stale hash - the package changed again since the admin reviewed it`() { + assertThatThrownBy { service.acceptContent(definition.id, "sha256:stale") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("changed again") + + assertThat(definition.contentHash).isEqualTo("sha256:accepted") + assertThat(definition.pendingContentHash).isEqualTo("sha256:changed") + verify(definitionRepository, never()).save(any()) + } + + @Test + fun `acceptContent rejects a definition without a pending change`() { + definition.pendingContentHash = null + + assertThatThrownBy { service.acceptContent(definition.id, "sha256:whatever") } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("no pending content change") + verify(definitionRepository, never()).save(any()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryServiceTest.kt new file mode 100644 index 0000000000..db32fe99e8 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginDiscoveryServiceTest.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import java.util.Optional +import java.util.UUID + +class ExternalPluginDiscoveryServiceTest { + + private val objectMapper = ObjectMapper() + private val failureThreshold = 3 + + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var configurationService: ExternalPluginConfigurationService + private lateinit var hostService: ExternalPluginHostService + private lateinit var hostClient: ExternalPluginHostClient + private lateinit var service: ExternalPluginDiscoveryService + + @BeforeEach + fun setUp() { + hostRepository = mock() + definitionRepository = mock() + configurationRepository = mock() + configurationService = mock() + hostService = mock() + hostClient = mock() + val transactionManager = mock() + whenever(transactionManager.getTransaction(anyOrNull())).thenReturn(SimpleTransactionStatus()) + service = ExternalPluginDiscoveryService( + hostRepository, + definitionRepository, + configurationRepository, + configurationService, + hostService, + hostClient, + TransactionTemplate(transactionManager), + failureThreshold, + ) + } + + @Test + fun `host flips to UNREACHABLE only after the configured number of consecutive failures`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED, consecutiveFailures = failureThreshold - 2) + givenHost(host) + whenever(hostClient.health(host.baseUrl)).thenReturn(false) + + service.discoverAll() + + assertThat(host.consecutiveFailures).isEqualTo(failureThreshold - 1) + assertThat(host.status).isEqualTo(ExternalPluginHostStatus.CONNECTED) + assertThat(host.lastHealthCheck).isNotNull() + + // One more failed cycle reaches the threshold and flips the status. + service.discoverAll() + + assertThat(host.consecutiveFailures).isEqualTo(failureThreshold) + assertThat(host.status).isEqualTo(ExternalPluginHostStatus.UNREACHABLE) + // An unhealthy host is never asked for its plugin list. + verify(hostClient, never()).listPlugins(any(), any()) + } + + @Test + fun `healthy poll resets the failure counter and reconnects the host`() { + val host = host(status = ExternalPluginHostStatus.UNREACHABLE, consecutiveFailures = failureThreshold) + givenHost(host) + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn(emptyList()) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(emptyList()) + + service.discoverAll() + + assertThat(host.consecutiveFailures).isEqualTo(0) + assertThat(host.status).isEqualTo(ExternalPluginHostStatus.CONNECTED) + } + + @Test + fun `discovered manifest is upserted as a new definition`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn( + listOf( + objectMapper.readTree( + """ + { + "pluginId": "case-summary", + "version": "1.2.0", + "manifest": { + "pluginId": "case-summary", + "version": "1.2.0", + "provider": "Ritense", + "compatibility": {"minGzacVersion": "12.0.0"}, + "translations": {"en": {"name": "Case summary", "description": "Summarises a case"}}, + "configurationSchema": {"type": "object", "properties": {"apiKey": {"type": "string", "x-secret": true}}} + } + } + """.trimIndent(), + ), + ), + ) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.2.0")).thenReturn(null) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(emptyList()) + + service.discoverAll() + + val captor = argumentCaptor() + verify(definitionRepository).save(captor.capture()) + val saved = captor.firstValue + assertThat(saved.pluginId).isEqualTo("case-summary") + assertThat(saved.version).isEqualTo("1.2.0") + assertThat(saved.hostId).isEqualTo(host.id) + assertThat(saved.name).isEqualTo("Case summary") + assertThat(saved.description).isEqualTo("Summarises a case") + assertThat(saved.provider).isEqualTo("Ritense") + assertThat(saved.minGzacVersion).isEqualTo("12.0.0") + assertThat(saved.status).isEqualTo(ExternalPluginDefinitionStatus.AVAILABLE) + assertThat(saved.configSchema?.path("properties")?.path("apiKey")?.path("x-secret")?.asBoolean()).isTrue() + assertThat(saved.baseUrl).isEqualTo("${host.baseUrl}/plugins/case-summary") + } + + @Test + fun `existing configurations are re-pushed to a healthy host`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id) + val configuration = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definition.id, + title = "Primary", + ) + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn(emptyList()) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(configurationService.pushToHost(configuration, definition, host)).thenReturn(true) + + service.discoverAll() + + verify(configurationService).pushToHost(configuration, definition, host) + } + + @Test + fun `push failure of one configuration does not abort the discovery cycle`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id) + val failing = ExternalPluginConfiguration(UUID.randomUUID(), definition.id, "Failing") + val healthy = ExternalPluginConfiguration(UUID.randomUUID(), definition.id, "Healthy") + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn(emptyList()) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(failing, healthy)) + whenever(configurationService.pushToHost(failing, definition, host)).thenThrow(RuntimeException("boom")) + whenever(configurationService.pushToHost(healthy, definition, host)).thenReturn(true) + + service.discoverAll() + + verify(configurationService).pushToHost(healthy, definition, host) + } + + @Test + fun `definition missing from the manifest list is marked UNAVAILABLE after the threshold`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id, consecutiveMisses = failureThreshold - 1) + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn(emptyList()) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + + service.discoverAll() + + assertThat(definition.consecutiveMisses).isEqualTo(failureThreshold) + assertThat(definition.status).isEqualTo(ExternalPluginDefinitionStatus.UNAVAILABLE) + verify(definitionRepository).save(definition) + } + + @Test + fun `pins the package content hash on first discovery`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + givenPluginListing(host, pluginEntry(contentHash = "sha256:aaa")) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(null) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(emptyList()) + + service.discoverAll() + + val captor = argumentCaptor() + verify(definitionRepository).save(captor.capture()) + assertThat(captor.firstValue.contentHash).isEqualTo("sha256:aaa") + assertThat(captor.firstValue.pendingContentHash).isNull() + } + + @Test + fun `backfills the content hash for a definition discovered before hashing existed`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id, contentHash = null) + givenPluginListing(host, pluginEntry(contentHash = "sha256:aaa")) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(definition) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + + service.discoverAll() + + assertThat(definition.contentHash).isEqualTo("sha256:aaa") + assertThat(definition.pendingContentHash).isNull() + } + + @Test + fun `flags a changed package for re-acceptance and freezes the accepted manifest data`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id, contentHash = "sha256:aaa").apply { name = "Accepted name" } + givenPluginListing(host, pluginEntry(contentHash = "sha256:bbb", name = "Tampered name")) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(definition) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + + service.discoverAll() + + assertThat(definition.contentHash).isEqualTo("sha256:aaa") + assertThat(definition.pendingContentHash).isEqualTo("sha256:bbb") + // The stored manifest data reflects what the admin accepted, not the changed package. + assertThat(definition.name).isEqualTo("Accepted name") + assertThat(definition.status).isEqualTo(ExternalPluginDefinitionStatus.AVAILABLE) + } + + @Test + fun `withholds configuration pushes for a definition awaiting re-acceptance`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id, contentHash = "sha256:aaa", pendingContentHash = "sha256:bbb") + val configuration = ExternalPluginConfiguration(UUID.randomUUID(), definition.id, "Primary") + givenPluginListing(host, pluginEntry(contentHash = "sha256:bbb")) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(definition) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + + service.discoverAll() + + verify(configurationService, never()).pushToHost(any(), any(), any()) + } + + @Test + fun `clears the re-acceptance flag when the host serves the pinned content again`() { + val host = host(status = ExternalPluginHostStatus.CONNECTED) + givenHost(host) + val definition = definition(hostId = host.id, contentHash = "sha256:aaa", pendingContentHash = "sha256:bbb") + givenPluginListing(host, pluginEntry(contentHash = "sha256:aaa")) + whenever(definitionRepository.findByPluginIdAndVersion("case-summary", "1.0.0")).thenReturn(definition) + whenever(definitionRepository.findAllByHostId(host.id)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + + service.discoverAll() + + assertThat(definition.contentHash).isEqualTo("sha256:aaa") + assertThat(definition.pendingContentHash).isNull() + } + + private fun givenPluginListing(host: ExternalPluginHost, vararg entries: JsonNode) { + whenever(hostClient.health(host.baseUrl)).thenReturn(true) + whenever(hostService.decryptedSecret(host)).thenReturn("admin-token") + whenever(hostClient.listPlugins(host.baseUrl, "admin-token")).thenReturn(entries.toList()) + } + + private fun pluginEntry(contentHash: String, name: String = "Case summary") = objectMapper.readTree( + """ + { + "pluginId": "case-summary", + "version": "1.0.0", + "contentHash": "$contentHash", + "manifest": { + "pluginId": "case-summary", + "version": "1.0.0", + "translations": {"en": {"name": "$name", "description": "Summarises a case"}} + } + } + """.trimIndent(), + ) + + private fun givenHost(host: ExternalPluginHost) { + whenever(hostRepository.findAll()).thenReturn(listOf(host)) + whenever(hostRepository.findById(eq(host.id))).thenReturn(Optional.of(host)) + } + + private fun host( + status: ExternalPluginHostStatus, + consecutiveFailures: Int = 0, + ): ExternalPluginHost = ExternalPluginHost( + id = UUID.randomUUID(), + name = "host", + baseUrl = "https://plugin-host.example.com", + secret = "encrypted-secret", + status = status, + consecutiveFailures = consecutiveFailures, + ) + + private fun definition( + hostId: UUID, + consecutiveMisses: Int = 0, + contentHash: String? = null, + pendingContentHash: String? = null, + ): ExternalPluginDefinition = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "case-summary", + version = "1.0.0", + hostId = hostId, + baseUrl = "https://plugin-host.example.com/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + consecutiveMisses = consecutiveMisses, + contentHash = contentHash, + pendingContentHash = pendingContentHash, + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceDeleteTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceDeleteTest.kt new file mode 100644 index 0000000000..da01143459 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceDeleteTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.exception.ExternalPluginHostInUseException +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.UUID + +class ExternalPluginHostServiceDeleteTest { + + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var grantedEndpointRepository: ExternalPluginGrantedEndpointRepository + private lateinit var grantedEventRepository: ExternalPluginGrantedEventRepository + private lateinit var hostUsageResolver: ExternalPluginHostUsageResolver + private lateinit var service: ExternalPluginHostService + + @BeforeEach + fun setUp() { + hostRepository = mock() + definitionRepository = mock() + configurationRepository = mock() + grantedEndpointRepository = mock() + grantedEventRepository = mock() + hostUsageResolver = mock() + service = ExternalPluginHostService( + hostRepository, + definitionRepository, + configurationRepository, + grantedEndpointRepository, + grantedEventRepository, + mock(), + mock(), + mock(), + hostUsageResolver, + ) + } + + @Test + fun `delete throws when usages exist and does not touch any repository`() { + val hostId = UUID.randomUUID() + val usages = listOf( + PluginUsageDto( + configurationId = UUID.randomUUID(), + configurationTitle = "Primary CRM", + parentType = PluginUsageParentType.CASE, + parentKey = "complaint", + parentVersionTag = "1.0.0", + processDefinitionId = "complaint-intake:3:abc", + processDefinitionKey = "complaint-intake", + processDefinitionName = "Complaint intake", + activityId = "SendLetter", + activityName = "Send letter to citizen", + processLinkId = UUID.randomUUID(), + ) + ) + whenever(hostUsageResolver.findUsagesForHost(hostId)).thenReturn(usages) + + assertThatThrownBy { service.delete(hostId) } + .isInstanceOf(ExternalPluginHostInUseException::class.java) + .satisfies({ thrown -> + val problem = thrown as ExternalPluginHostInUseException + assertThat(problem.parameters["hostId"]).isEqualTo(hostId.toString()) + @Suppress("UNCHECKED_CAST") + val payloadUsages = problem.parameters["usages"] as Collection + assertThat(payloadUsages).hasSize(1) + assertThat(payloadUsages.first().activityName).isEqualTo("Send letter to citizen") + }) + + verify(definitionRepository, never()).findAllByHostId(any()) + verify(configurationRepository, never()).findAllByDefinitionId(any()) + verify(grantedEndpointRepository, never()).deleteAllByConfigurationId(any()) + verify(grantedEventRepository, never()).deleteAllByConfigurationId(any()) + verify(configurationRepository, never()).deleteAll(any>()) + verify(definitionRepository, never()).deleteAll(any>()) + verify(hostRepository, never()).deleteById(any()) + } + + @Test + fun `findUsages delegates to the resolver and never deletes`() { + val hostId = UUID.randomUUID() + val expected = listOf( + PluginUsageDto( + configurationId = UUID.randomUUID(), + configurationTitle = "Primary CRM", + parentType = PluginUsageParentType.GLOBAL, + parentKey = null, + parentVersionTag = null, + processDefinitionId = "complaint-intake:3:abc", + processDefinitionKey = null, + processDefinitionName = null, + activityId = "SendLetter", + activityName = null, + processLinkId = UUID.randomUUID(), + ) + ) + whenever(hostUsageResolver.findUsagesForHost(hostId)).thenReturn(expected) + + val usages = service.findUsages(hostId) + + assertThat(usages).isSameAs(expected) + verify(hostRepository, never()).deleteById(any()) + verify(definitionRepository, never()).deleteAll(any>()) + } + + @Test + fun `delete cascades when no usages exist`() { + val hostId = UUID.randomUUID() + val definition = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "test-plugin", + version = "1.0.0", + hostId = hostId, + baseUrl = "https://host.example", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + val configuration = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definition.id, + title = "Configuration", + ) + whenever(hostUsageResolver.findUsagesForHost(hostId)).thenReturn(emptyList()) + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + + service.delete(hostId) + + verify(grantedEndpointRepository).deleteAllByConfigurationId(configuration.id) + verify(grantedEventRepository).deleteAllByConfigurationId(configuration.id) + verify(configurationRepository).deleteAll(listOf(configuration)) + verify(definitionRepository).deleteAll(listOf(definition)) + verify(hostRepository).deleteById(hostId) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceTest.kt new file mode 100644 index 0000000000..e6dfac5379 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostServiceTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.client.ExternalPluginHostClient +import com.ritense.externalplugin.domain.EventQueueMode +import com.ritense.externalplugin.exception.ExternalPluginNotFoundException +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostKind +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEventRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.plugin.service.EncryptionService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +/** + * Guards the rule that the broker AMQP URL and credentials — carried in cleartext inside the + * HMAC-signed configuration push body — are never associated with a host the push can only reach + * over an unencrypted transport. Registration is the single enforcement point because the host base + * URL cannot change afterwards. + */ +class ExternalPluginHostServiceTest { + + private lateinit var hostRepository: ExternalPluginHostRepository + private lateinit var encryptionService: EncryptionService + private lateinit var service: ExternalPluginHostService + + @BeforeEach + fun setUp() { + hostRepository = mock() + encryptionService = mock() + whenever(encryptionService.encrypt(any())).thenReturn("encrypted-secret") + whenever(hostRepository.save(any())).thenAnswer { it.getArgument(0) } + service = ExternalPluginHostService( + hostRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + encryptionService, + mock(), + mock(), + ) + } + + @Test + fun `allows broker credentials over https`() { + val host = service.register( + name = "remote", + baseUrl = "https://plugin-host.example.com", + secret = "admin-token", + gzacCallbackBaseUrl = "https://gzac.example.com", + eventBrokerAmqpUrl = "amqp://guest:guest@broker:5672", + eventBrokerExchange = null, + ) + + assertThat(host.baseUrl).isEqualTo("https://plugin-host.example.com") + assertThat(host.eventBrokerAmqpUrl).isEqualTo("amqp://guest:guest@broker:5672") + } + + @Test + fun `allows broker credentials over loopback http for local development`() { + listOf("http://localhost:8090", "http://127.0.0.1:8090").forEach { baseUrl -> + val host = service.register( + name = "local", + baseUrl = baseUrl, + secret = "admin-token", + gzacCallbackBaseUrl = "http://localhost:8080", + eventBrokerAmqpUrl = "amqp://guest:guest@localhost:5672", + eventBrokerExchange = null, + ) + + assertThat(host.eventBrokerAmqpUrl).isEqualTo("amqp://guest:guest@localhost:5672") + } + } + + @Test + fun `rejects broker credentials over plaintext http to a remote host`() { + assertThatThrownBy { + service.register( + name = "remote", + baseUrl = "http://plugin-host:8090", + secret = "admin-token", + gzacCallbackBaseUrl = "http://localhost:8080", + eventBrokerAmqpUrl = "amqp://guest:guest@broker:5672", + eventBrokerExchange = null, + ) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("unencrypted transport") + } + + @Test + fun `allows a plaintext remote host when no broker is configured`() { + val host = service.register( + name = "actions-only", + baseUrl = "http://plugin-host:8090", + secret = "admin-token", + gzacCallbackBaseUrl = "http://localhost:8080", + eventBrokerAmqpUrl = null, + eventBrokerExchange = null, + ) + + assertThat(host.baseUrl).isEqualTo("http://plugin-host:8090") + assertThat(host.eventBrokerAmqpUrl).isNull() + } + + @Test + fun `treats a blank broker url as no broker`() { + val host = service.register( + name = "actions-only", + baseUrl = "http://plugin-host:8090", + secret = "admin-token", + gzacCallbackBaseUrl = "http://localhost:8080", + eventBrokerAmqpUrl = " ", + eventBrokerExchange = null, + ) + + assertThat(host.eventBrokerAmqpUrl).isNull() + } + + @Test + fun `classifies confidential transports`() { + assertThat(ExternalPluginHostService.isSecureTransport("https://plugin-host:8090")).isTrue() + assertThat(ExternalPluginHostService.isSecureTransport("HTTPS://plugin-host:8090")).isTrue() + assertThat(ExternalPluginHostService.isSecureTransport("http://localhost:8090")).isTrue() + assertThat(ExternalPluginHostService.isSecureTransport("http://127.0.0.1:8090")).isTrue() + assertThat(ExternalPluginHostService.isSecureTransport("http://[::1]:8090")).isTrue() + } + + @Test + fun `classifies eavesdroppable transports`() { + assertThat(ExternalPluginHostService.isSecureTransport("http://plugin-host:8090")).isFalse() + assertThat(ExternalPluginHostService.isSecureTransport("http://10.0.0.5:8090")).isFalse() + assertThat(ExternalPluginHostService.isSecureTransport("plugin-host:8090")).isFalse() + } + + @Test + fun `register defaults event queue mode to LIVE and TTL to null`() { + val host = registerMinimal() + + assertThat(host.eventQueueMode).isEqualTo(EventQueueMode.LIVE) + assertThat(host.eventQueueTtlMs).isNull() + } + + @Test + fun `register with DURABLE mode and no TTL applies the 72h default`() { + val host = registerMinimal(mode = EventQueueMode.DURABLE, ttlMs = null) + + assertThat(host.eventQueueMode).isEqualTo(EventQueueMode.DURABLE) + assertThat(host.eventQueueTtlMs).isEqualTo(ExternalPluginHostService.DEFAULT_EVENT_QUEUE_TTL_MS) + } + + @Test + fun `register with DURABLE mode honours an explicit TTL inside the allowed range`() { + val host = registerMinimal(mode = EventQueueMode.DURABLE, ttlMs = 6L * 60 * 60 * 1000) + + assertThat(host.eventQueueTtlMs).isEqualTo(6L * 60 * 60 * 1000) + } + + @Test + fun `register with DURABLE mode rejects a TTL below 1 hour`() { + assertThatThrownBy { + registerMinimal(mode = EventQueueMode.DURABLE, ttlMs = 60_000) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("eventQueueTtlMs must be between") + } + + @Test + fun `register with DURABLE mode rejects a TTL above 30 days`() { + assertThatThrownBy { + registerMinimal(mode = EventQueueMode.DURABLE, ttlMs = 31L * 24 * 60 * 60 * 1000) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("eventQueueTtlMs must be between") + } + + @Test + fun `register with LIVE mode rejects a non-null TTL`() { + assertThatThrownBy { + registerMinimal(mode = EventQueueMode.LIVE, ttlMs = 60L * 60 * 1000) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must be null when eventQueueMode is LIVE") + } + + @Test + fun `updateEventQueue swaps mode from LIVE to DURABLE with the default TTL`() { + val existing = registerMinimal() + whenever(hostRepository.findById(existing.id)).thenReturn(Optional.of(existing)) + + val updated = service.updateEventQueue(existing.id, EventQueueMode.DURABLE, null) + + assertThat(updated.eventQueueMode).isEqualTo(EventQueueMode.DURABLE) + assertThat(updated.eventQueueTtlMs).isEqualTo(ExternalPluginHostService.DEFAULT_EVENT_QUEUE_TTL_MS) + } + + @Test + fun `updateEventQueue clears TTL when downgrading from DURABLE to LIVE`() { + val existing = registerMinimal(mode = EventQueueMode.DURABLE, ttlMs = 6L * 60 * 60 * 1000) + whenever(hostRepository.findById(existing.id)).thenReturn(Optional.of(existing)) + + val updated = service.updateEventQueue(existing.id, EventQueueMode.LIVE, null) + + assertThat(updated.eventQueueMode).isEqualTo(EventQueueMode.LIVE) + assertThat(updated.eventQueueTtlMs).isNull() + } + + @Test + fun `updateEventQueue with LIVE mode rejects a non-null TTL`() { + val existing = registerMinimal() + whenever(hostRepository.findById(existing.id)).thenReturn(Optional.of(existing)) + + assertThatThrownBy { + service.updateEventQueue(existing.id, EventQueueMode.LIVE, 60L * 60 * 1000) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must be null when eventQueueMode is LIVE") + } + + @Test + fun `register defaults kind to PLUGIN_HOST`() { + assertThat(registerMinimal().kind).isEqualTo(ExternalPluginHostKind.PLUGIN_HOST) + } + + @Test + fun `register persists the APP kind`() { + val host = service.register( + name = "demo-app", + baseUrl = "https://demo-app.example.com", + secret = "admin-token", + gzacCallbackBaseUrl = "https://gzac.example.com", + eventBrokerAmqpUrl = null, + eventBrokerExchange = null, + kind = ExternalPluginHostKind.APP, + ) + + assertThat(host.kind).isEqualTo(ExternalPluginHostKind.APP) + } + + @Test + fun `uploadPlugin rejects an app host`() { + val app = service.register( + name = "demo-app", + baseUrl = "https://demo-app.example.com", + secret = "admin-token", + gzacCallbackBaseUrl = "https://gzac.example.com", + eventBrokerAmqpUrl = null, + eventBrokerExchange = null, + kind = ExternalPluginHostKind.APP, + ) + whenever(hostRepository.findById(app.id)).thenReturn(Optional.of(app)) + + assertThatThrownBy { + service.uploadPlugin(app.id, "plugin.zip", ByteArray(0)) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("does not accept plugin uploads") + } + + @Test + fun `updateEventQueue throws when the host does not exist`() { + val missingId = UUID.randomUUID() + whenever(hostRepository.findById(missingId)).thenReturn(Optional.empty()) + + assertThatThrownBy { + service.updateEventQueue(missingId, EventQueueMode.LIVE, null) + }.isInstanceOf(ExternalPluginNotFoundException::class.java) + .hasMessageContaining("not found") + } + + private fun registerMinimal( + mode: EventQueueMode = EventQueueMode.LIVE, + ttlMs: Long? = null, + ): ExternalPluginHost = service.register( + name = "local", + baseUrl = "https://plugin-host.example.com", + secret = "admin-token", + gzacCallbackBaseUrl = "https://gzac.example.com", + eventBrokerAmqpUrl = "amqp://guest:guest@broker:5672", + eventBrokerExchange = null, + eventQueueMode = mode, + eventQueueTtlMs = ttlMs, + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt new file mode 100644 index 0000000000..4ccb037154 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt @@ -0,0 +1,816 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.case_.service.CaseExternalPluginWidgetService +import com.ritense.case_.service.CaseExternalPluginWidgetUsage +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginMappingUsage +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder +import com.ritense.plugin.service.ProcessDefinitionUsageMetaResolver +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.model.bpmn.BpmnModelInstance +import org.operaton.bpm.model.bpmn.instance.FlowElement +import java.util.UUID + +class ExternalPluginHostUsageResolverTest { + + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + private lateinit var taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository + private lateinit var operatonRepositoryService: OperatonRepositoryService + private lateinit var bpmnRepositoryService: RepositoryService + private lateinit var resolver: ExternalPluginHostUsageResolver + + @BeforeEach + fun setUp() { + definitionRepository = mock() + configurationRepository = mock() + processLinkRepository = mock() + taskFormProcessLinkRepository = mock() + operatonRepositoryService = mock() + bpmnRepositoryService = mock() + whenever(processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn(any(), any())) + .thenReturn(emptyList()) + resolver = ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + // Real shared resolver over the mocked Operaton services — the tests keep asserting + // the full resolution behaviour through it. + ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), + java.util.Optional.empty(), + java.util.Optional.empty(), + ) + } + + @Test + fun `host with no definitions returns empty list`() { + val hostId = UUID.randomUUID() + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(emptyList()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).isEmpty() + verify(processLinkRepository, never()).findAllByExternalPluginConfigurationIdIn(any()) + } + + @Test + fun `host with definitions but no configurations returns empty list`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).isEmpty() + verify(processLinkRepository, never()).findAllByExternalPluginConfigurationIdIn(any()) + } + + @Test + fun `configuration present but no process links returns empty list`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).isEmpty() + } + + /** + * A `BUILDING_BLOCK`-reference `ExternalPluginProcessLink` carries a `null` + * `externalPluginConfigurationId` (Phase 2) — it never counts as a *configuration* usage (the + * link pins no configuration; the mapping does, covered by + * [BuildingBlockPluginMappingUsageFinder][com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder]). + * It blocks *host* deletion only through the definition-reference guard, which matches on the + * pinned `pluginId@version` — here the link pins `plugin`, not the host's `test-plugin`, so no + * definition usage surfaces either. The repository query itself (`IN (...)` over non-null ids) + * already excludes such rows from configuration usage; this exercises the resolver's own + * `mapNotNull` defense so the guarantee holds even if a caller ever passes a broader result set. + */ + @Test + fun `BUILDING_BLOCK link with null configuration id is ignored by the delete guard`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val fixedLink = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = "bezwaar:3:abc", + activityId = "SendLetter", + ) + val buildingBlockLink = buildingBlockReferenceProcessLink( + processDefinitionId = "send-notification:2:bb-hash", + activityId = "PostMessage", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(fixedLink, buildingBlockLink)) + whenever(operatonRepositoryService.findProcessDefinitionById("bezwaar:3:abc")).thenReturn( + operatonProcessDefinition( + id = "bezwaar:3:abc", + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance("bezwaar:3:abc")).thenReturn(mock()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + assertThat(usages[0].processLinkId).isEqualTo(fixedLink.id) + assertThat(usages.map { it.processDefinitionId }).doesNotContain("send-notification:2:bb-hash") + } + + @Test + fun `process tied to a case definition is classified as CASE`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id, title = "Primary CRM") + val processDefId = "bezwaar:3:abc" + val link = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "SendLetter", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(link)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + val model = bpmnModelWithActivity("SendLetter", "Send letter to citizen") + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(usage.parentKey).isEqualTo("bezwaar") + assertThat(usage.parentVersionTag).isEqualTo("1.0.1") + assertThat(usage.processDefinitionKey).isEqualTo("bezwaar") + assertThat(usage.processDefinitionName).isEqualTo("Bezwaarprocedure") + assertThat(usage.activityName).isEqualTo("Send letter to citizen") + } + + @Test + fun `process tied to a building block is classified as BUILDING_BLOCK`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val processDefId = "send-notification:2:bb-hash" + val link = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "PostMessage", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(link)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "send-notification", + name = "Send notification", + versionTag = "BB:send-notification:2.0.0", + ) + ) + val model = bpmnModelWithActivity("PostMessage", "Post status update") + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.BUILDING_BLOCK) + assertThat(usage.parentKey).isEqualTo("send-notification") + assertThat(usage.parentVersionTag).isEqualTo("2.0.0") + assertThat(usage.processDefinitionKey).isEqualTo("send-notification") + assertThat(usage.processDefinitionName).isEqualTo("Send notification") + } + + @Test + fun `process with no version tag prefix is classified as GLOBAL`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val processDefId = "global-housekeeping:1:xyz" + val link = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "Cleanup", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(link)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "global-housekeeping", + name = "Global housekeeping", + versionTag = null, + ) + ) + val model = bpmnModelWithActivity("Cleanup", "Cleanup step") + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.GLOBAL) + assertThat(usage.parentKey).isNull() + assertThat(usage.parentVersionTag).isNull() + assertThat(usage.processDefinitionKey).isEqualTo("global-housekeeping") + assertThat(usage.processDefinitionName).isEqualTo("Global housekeeping") + } + + @Test + fun `process definition lookup failure degrades to GLOBAL with nullable fields`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id, title = "Broken Reference") + val processDefId = "missing:9:no-such" + val link = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "Unknown", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(link)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)) + .thenThrow(RuntimeException("process definition gone")) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)) + .thenThrow(RuntimeException("model gone")) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.GLOBAL) + assertThat(usage.parentKey).isNull() + assertThat(usage.parentVersionTag).isNull() + assertThat(usage.processDefinitionKey).isNull() + assertThat(usage.processDefinitionName).isNull() + assertThat(usage.activityName).isNull() + assertThat(usage.configurationId).isEqualTo(configuration.id) + assertThat(usage.processDefinitionId).isEqualTo(processDefId) + assertThat(usage.activityId).isEqualTo("Unknown") + } + + @Test + fun `findUsagesForConfiguration returns links targeting only that configuration`() { + val configuration = configuration(definitionId = UUID.randomUUID(), title = "Primary CRM") + val processDefId = "bezwaar:3:abc" + val link = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "SendLetter", + ) + + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(link)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + val model = bpmnModelWithActivity("SendLetter", "Send letter to citizen") + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + val usages = resolver.findUsagesForConfiguration(configuration.id) + + assertThat(usages).hasSize(1) + assertThat(usages[0].configurationId).isEqualTo(configuration.id) + assertThat(usages[0].parentType).isEqualTo(PluginUsageParentType.CASE) + } + + @Test + fun `findUsagesForConfiguration returns empty when the configuration is unknown`() { + val missingId = UUID.randomUUID() + whenever(configurationRepository.findById(missingId)).thenReturn(java.util.Optional.empty()) + + val usages = resolver.findUsagesForConfiguration(missingId) + + assertThat(usages).isEmpty() + verify(processLinkRepository, never()).findAllByExternalPluginConfigurationIdIn(any()) + } + + @Test + fun `findUsagesForConfiguration returns empty when no process links reference it`() { + val configuration = configuration(definitionId = UUID.randomUUID()) + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + + val usages = resolver.findUsagesForConfiguration(configuration.id) + + assertThat(usages).isEmpty() + } + + @Test + fun `process metadata is cached across links pointing at the same process definition`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val processDefId = "bezwaar:3:abc" + val linkA = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "StepA", + ) + val linkB = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "StepB", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(linkA, linkB)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + val model = mock() + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + resolver.findUsagesForHost(hostId) + + verify(operatonRepositoryService).findProcessDefinitionById(processDefId) + verify(bpmnRepositoryService).getBpmnModelInstance(processDefId) + } + + @Test + fun `task-form process links are included in the usages`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id, title = "Form Plugin") + val processDefId = "bezwaar:3:abc" + val taskFormLink = taskFormProcessLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "ReviewTask", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(taskFormLink)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + val model = bpmnModelWithActivity("ReviewTask", "Review request") + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(model) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.configurationId).isEqualTo(configuration.id) + assertThat(usage.processLinkId).isEqualTo(taskFormLink.id) + assertThat(usage.activityId).isEqualTo("ReviewTask") + assertThat(usage.activityName).isEqualTo("Review request") + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.CASE) + } + + @Test + fun `usages union service-task action and user-task form links for the same configuration`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val processDefId = "bezwaar:3:abc" + val actionLink = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "SendLetter", + ) + val taskFormLink = taskFormProcessLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = processDefId, + activityId = "ReviewTask", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(actionLink)) + whenever(taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(taskFormLink)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(mock()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(2) + assertThat(usages.map { it.processLinkId }) + .containsExactlyInAnyOrder(actionLink.id, taskFormLink.id) + assertThat(usages.map { it.activityId }) + .containsExactlyInAnyOrder("SendLetter", "ReviewTask") + } + + @Test + fun `configuration referenced only by building-block mappings still blocks deletion`() { + val configuration = configuration(definitionId = UUID.randomUUID(), title = "Mapped CRM") + val mappingUsageFinder = mock() + val resolverWithFinder = resolverWith(mappingUsageFinder) + val processDefId = "bezwaar:3:abc" + + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(mappingUsageFinder.findUsages(configuration.id)).thenReturn( + listOf( + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + processLinkId = UUID.randomUUID(), + processDefinitionId = processDefId, + activityId = "CallSendNotification", + ), + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + ), + ) + ) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(mock()) + + val usages = resolverWithFinder.findUsagesForConfiguration(configuration.id) + + assertThat(usages).hasSize(2) + val processLinkUsage = usages.single { it.processDefinitionId != null } + assertThat(processLinkUsage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(processLinkUsage.activityId).isEqualTo("CallSendNotification") + val caseLinkUsage = usages.single { it.processDefinitionId == null } + assertThat(caseLinkUsage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(caseLinkUsage.parentKey).isEqualTo("bezwaar") + assertThat(caseLinkUsage.parentVersionTag).isEqualTo("1.0.1") + assertThat(usages).allSatisfy { + assertThat(it.configurationId).isEqualTo(configuration.id) + assertThat(it.configurationTitle).isEqualTo("Mapped CRM") + assertThat(it.buildingBlockKey).isEqualTo("send-notification") + } + } + + @Test + fun `host deletion is blocked by a BUILDING_BLOCK reference pinning one of its definitions`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) // test-plugin@1.0.0 + val processDefId = "send-notification:2:bb-hash" + val referenceLink = buildingBlockReferenceProcessLink( + processDefinitionId = processDefId, + activityId = "PostMessage", + pluginDefinitionKey = definition.pluginId, + pluginVersion = definition.version, + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + whenever( + processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + setOf(definition.pluginId), + ) + ).thenReturn(listOf(referenceLink)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "send-notification", + name = "Send notification", + versionTag = "BB:send-notification:2.0.0", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(mock()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.configurationId).isEqualTo(definition.id) + assertThat(usage.configurationTitle).isEqualTo("test-plugin@1.0.0") + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.BUILDING_BLOCK) + assertThat(usage.parentKey).isEqualTo("send-notification") + assertThat(usage.processLinkId).isEqualTo(referenceLink.id) + } + + @Test + fun `a BUILDING_BLOCK reference pinned to a version the host does not serve does not block it`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) // test-plugin@1.0.0 + val referenceLink = buildingBlockReferenceProcessLink( + processDefinitionId = "send-notification:2:bb-hash", + activityId = "PostMessage", + pluginDefinitionKey = definition.pluginId, + pluginVersion = "9.9.9", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + whenever( + processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + setOf(definition.pluginId), + ) + ).thenReturn(listOf(referenceLink)) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).isEmpty() + } + + @Test + fun `host deletion is blocked by building-block mappings referencing its configurations`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id, title = "Mapped CRM") + val mappingUsageFinder = mock() + val resolverWithFinder = resolverWith(mappingUsageFinder) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(mappingUsageFinder.findUsages(configuration.id)).thenReturn( + listOf( + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + ), + ) + ) + + val usages = resolverWithFinder.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + assertThat(usages[0].configurationId).isEqualTo(configuration.id) + assertThat(usages[0].parentKey).isEqualTo("bezwaar") + } + + @Test + fun `configuration referenced only by an external-plugin widget still blocks deletion`() { + val configuration = configuration(definitionId = UUID.randomUUID(), title = "Summary Plugin") + val widgetService = mock() + val resolverWithWidgets = resolverWithWidgetService(widgetService) + + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(widgetService.findUsagesForConfiguration(configuration.id)).thenReturn( + listOf( + CaseExternalPluginWidgetUsage( + configurationId = configuration.id, + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + tabKey = "summary", + tabName = "Summary", + widgetKey = "summary-widget", + ), + ) + ) + + val usages = resolverWithWidgets.findUsagesForConfiguration(configuration.id) + + assertThat(usages).hasSize(1) + val usage = usages.single() + assertThat(usage.configurationId).isEqualTo(configuration.id) + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(usage.parentKey).isEqualTo("bezwaar") + assertThat(usage.parentVersionTag).isEqualTo("1.0.1") + assertThat(usage.tabKey).isEqualTo("summary") + assertThat(usage.tabName).isEqualTo("Summary") + assertThat(usage.widgetKey).isEqualTo("summary-widget") + assertThat(usage.processDefinitionId).isNull() + } + + private fun resolverWith(finder: BuildingBlockPluginMappingUsageFinder): ExternalPluginHostUsageResolver = + ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), + java.util.Optional.empty(), + java.util.Optional.empty(), + java.util.Optional.of(finder), + ) + + private fun resolverWithWidgetService( + widgetService: CaseExternalPluginWidgetService, + ): ExternalPluginHostUsageResolver = + ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), + java.util.Optional.empty(), + java.util.Optional.of(widgetService), + ) + + private fun definition(hostId: UUID): ExternalPluginDefinition = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "test-plugin", + version = "1.0.0", + hostId = hostId, + baseUrl = "https://host.example", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + + private fun configuration(definitionId: UUID, title: String = "Configuration"): ExternalPluginConfiguration = + ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definitionId, + title = title, + ) + + private fun processLink( + externalPluginConfigurationId: UUID, + processDefinitionId: String, + activityId: String, + ): ExternalPluginProcessLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = externalPluginConfigurationId, + actionKey = "action", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "plugin", + pluginDefinitionVersion = "1.0.0", + ), + ) + + private fun buildingBlockReferenceProcessLink( + processDefinitionId: String, + activityId: String, + pluginDefinitionKey: String = "plugin", + pluginVersion: String = "1.0.0", + ): ExternalPluginProcessLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "action", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginVersion, + ), + ) + + private fun taskFormProcessLink( + externalPluginConfigurationId: UUID, + processDefinitionId: String, + activityId: String, + ): ExternalPluginTaskFormProcessLink = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = externalPluginConfigurationId, + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "plugin", + pluginDefinitionVersion = "1.0.0", + ), + bundleKey = "review", + ) + + private fun operatonProcessDefinition( + id: String, + key: String, + name: String?, + versionTag: String?, + ): OperatonProcessDefinition = OperatonProcessDefinition( + id = id, + revision = 1, + category = null, + name = name, + key = key, + version = 1, + deploymentId = null, + resourceName = null, + diagramResourceName = null, + hasStartFormKey = false, + suspensionState = 1, + tenantId = null, + versionTag = versionTag, + historyTimeToLive = null, + isStartableInTasklist = true, + ) + + private fun bpmnModelWithActivity(activityId: String, activityName: String): BpmnModelInstance { + val element = mock { + on { name } doReturn activityName + } + return mock { + on { getModelElementById(activityId) } doReturn element + } + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageServiceTest.kt new file mode 100644 index 0000000000..b74400fe36 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginMenuPageServiceTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginMenuPageServiceTest { + + private val objectMapper = ObjectMapper() + private val configurationRepository = mock() + private val definitionRepository = mock() + private val bundleUrlResolver = ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository) + private val service = ExternalPluginMenuPageService(configurationRepository, definitionRepository, bundleUrlResolver) + + @Test + fun `lists only page bundles of available configurations with resolved url and localized title`() { + val availableConfigId = UUID.randomUUID() + val availableDefId = UUID.randomUUID() + val available = configuration(availableConfigId, availableDefId, "Overview config") + val availableDef = definition( + availableDefId, + ExternalPluginDefinitionStatus.AVAILABLE, + bundlesJson = """[ { "type":"config", "path":"/bundles/config.html" }, + { "type":"page", "key":"overview", "title":"page.overview.title", "icon":"home", "path":"/bundles/page.html" } ]""", + translationsJson = """{ "en": { "page.overview.title": "Overview" }, "nl": { "page.overview.title": "Overzicht" } }""", + ) + + val unavailableConfigId = UUID.randomUUID() + val unavailableDefId = UUID.randomUUID() + val unavailable = configuration(unavailableConfigId, unavailableDefId, "Hidden config") + val unavailableDef = definition( + unavailableDefId, + ExternalPluginDefinitionStatus.UNAVAILABLE, + bundlesJson = """[ { "type":"page", "key":"overview", "path":"/bundles/page.html" } ]""", + ) + + whenever(configurationRepository.findAll()).thenReturn(listOf(available, unavailable)) + whenever(configurationRepository.findById(availableConfigId)).thenReturn(Optional.of(available)) + whenever(configurationRepository.findById(unavailableConfigId)).thenReturn(Optional.of(unavailable)) + whenever(definitionRepository.findById(availableDefId)).thenReturn(Optional.of(availableDef)) + whenever(definitionRepository.findById(unavailableDefId)).thenReturn(Optional.of(unavailableDef)) + + val pages = service.getMenuPages() + + assertThat(pages).hasSize(1) + val page = pages.single() + assertThat(page.configurationId).isEqualTo(availableConfigId) + assertThat(page.bundleKey).isEqualTo("overview") + assertThat(page.bundleUrl).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/page.html") + assertThat(page.title).isEqualTo("page.overview.title") + assertThat(page.icon).isEqualTo("home") + assertThat(page.titleTranslations).containsEntry("en", "Overview").containsEntry("nl", "Overzicht") + } + + @Test + fun `returns empty when an available configuration has no page bundles`() { + val configId = UUID.randomUUID() + val defId = UUID.randomUUID() + val config = configuration(configId, defId, "Config") + val def = definition( + defId, + ExternalPluginDefinitionStatus.AVAILABLE, + bundlesJson = """[ { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""", + ) + whenever(configurationRepository.findAll()).thenReturn(listOf(config)) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(config)) + whenever(definitionRepository.findById(defId)).thenReturn(Optional.of(def)) + + assertThat(service.getMenuPages()).isEmpty() + } + + private fun configuration(id: UUID, definitionId: UUID, title: String) = + ExternalPluginConfiguration(id = id, definitionId = definitionId, title = title) + + private fun definition( + id: UUID, + status: ExternalPluginDefinitionStatus, + bundlesJson: String, + translationsJson: String? = null, + ): ExternalPluginDefinition { + val manifest = objectMapper.createObjectNode() + .set("frontendBundles", objectMapper.readTree(bundlesJson)) + if (translationsJson != null) { + manifest.set("translations", objectMapper.readTree(translationsJson)) + } + return ExternalPluginDefinition( + id = id, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://host:8090/plugins/case-summary", + status = status, + manifestJson = manifest, + ) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenServiceTest.kt new file mode 100644 index 0000000000..71e32b0c7c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginServiceTokenServiceTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.security.ExternalPluginServiceTokenKeyProvider +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Duration +import java.util.UUID + +class ExternalPluginServiceTokenServiceTest { + + private val secret = "test-secret-test-secret-test-secret-1234" + private val keyProvider = ExternalPluginServiceTokenKeyProvider(secret) + + @Test + fun `defaults to a 10 minute token lifetime`() { + // Deliberately short: the discovery poll re-pushes a fresh token every ~60s, so a longer + // default would only extend how long a leaked token stays usable. + val claims = issue(ExternalPluginServiceTokenService(keyProvider)) + + assertThat(Duration.between(claims.issuedAt.toInstant(), claims.expiration.toInstant())) + .isEqualTo(Duration.ofMinutes(10)) + } + + @Test + fun `honours the configured token lifetime`() { + val ttl = Duration.ofMinutes(15) + + val claims = issue(ExternalPluginServiceTokenService(keyProvider, ttl)) + + assertThat(Duration.between(claims.issuedAt.toInstant(), claims.expiration.toInstant())) + .isEqualTo(ttl) + } + + @Test + fun `stamps the configuration's current token generation into the token`() { + val claims = issue( + ExternalPluginServiceTokenService(keyProvider), + configuration = configuration(tokenGeneration = 5), + ) + + val generation = claims[ExternalPluginServiceTokenService.TOKEN_GENERATION_CLAIM] as Number + assertThat(generation.toLong()).isEqualTo(5L) + } + + private fun issue( + service: ExternalPluginServiceTokenService, + configuration: ExternalPluginConfiguration = configuration(), + ): Claims = + Jwts.parser() + .verifyWith(keyProvider.signingKey) + .build() + .parseSignedClaims(service.issue(configuration, definition())) + .payload + + private fun configuration(tokenGeneration: Long = 0) = ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = UUID.randomUUID(), + title = "test", + tokenGeneration = tokenGeneration, + ) + + private fun definition() = ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenServiceTest.kt new file mode 100644 index 0000000000..971c86b2db --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginUserTokenServiceTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.service + +import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Duration +import java.util.UUID + +class ExternalPluginUserTokenServiceTest { + + private val secret = "test-secret-test-secret-test-secret-1234" + private val keyProvider = ExternalPluginUserTokenKeyProvider(secret) + + @Test + fun `mints a token with the expected claims`() { + val configId = UUID.randomUUID() + val service = ExternalPluginUserTokenService(keyProvider) + + val issued = service.issue("john.doe", listOf("ROLE_USER", "ROLE_ADMIN"), configId, tokenGeneration = 7) + val claims = parse(issued.token) + + assertThat(claims.subject).isEqualTo("john.doe") + assertThat(claims[ExternalPluginUserTokenKeyProvider.TYPE_CLAIM]) + .isEqualTo(ExternalPluginUserTokenKeyProvider.TOKEN_TYPE) + assertThat(claims[ExternalPluginUserTokenService.PLUGIN_CONFIG_ID_CLAIM]) + .isEqualTo(configId.toString()) + @Suppress("UNCHECKED_CAST") + assertThat(claims[ExternalPluginUserTokenService.ROLES_CLAIM] as List) + .containsExactly("ROLE_USER", "ROLE_ADMIN") + assertThat((claims[ExternalPluginUserTokenService.TOKEN_GENERATION_CLAIM] as Number).toLong()) + .isEqualTo(7L) + } + + @Test + fun `defaults to a 15 minute lifetime`() { + val claims = + parse(ExternalPluginUserTokenService(keyProvider).issue("u", emptyList(), UUID.randomUUID(), 0).token) + + assertThat(Duration.between(claims.issuedAt.toInstant(), claims.expiration.toInstant())) + .isEqualTo(Duration.ofMinutes(15)) + } + + @Test + fun `caps an over-long configured lifetime at 15 minutes`() { + val service = ExternalPluginUserTokenService(keyProvider, Duration.ofHours(24)) + + val claims = parse(service.issue("u", emptyList(), UUID.randomUUID(), 0).token) + + assertThat(Duration.between(claims.issuedAt.toInstant(), claims.expiration.toInstant())) + .isEqualTo(Duration.ofMinutes(15)) + } + + private fun parse(token: String): Claims = + Jwts.parser() + .verifyWith(keyProvider.signingKey) + .build() + .parseSignedClaims(token) + .payload +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResourceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResourceTest.kt new file mode 100644 index 0000000000..4874c9549d --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginHostOriginsResourceTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.service.ExternalPluginHostService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.http.HttpStatus +import java.util.UUID + +/** + * The `host-origins` endpoint backs the frontend's CSP bootstrap for *every* authenticated user + * (audit-C2), so its payload must be strictly origins: no credentials, paths, or duplicate noise + * may survive, and unparseable rows must be dropped rather than break the whole response. + */ +class ExternalPluginHostOriginsResourceTest { + + private lateinit var hostService: ExternalPluginHostService + private lateinit var resource: ExternalPluginHostOriginsResource + + @BeforeEach + fun setUp() { + hostService = mock() + resource = ExternalPluginHostOriginsResource(hostService) + } + + @Test + fun `returns the distinct sorted origins of all registered hosts`() { + whenever(hostService.list()).thenReturn( + listOf( + host("https://plugins.example.com"), + host("http://localhost:3010"), + // Same origin as the first host, different path — must collapse to one origin. + host("https://plugins.example.com/other-root"), + ) + ) + + val response = resource.getHostOrigins() + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).containsExactly( + "http://localhost:3010", + "https://plugins.example.com", + ) + } + + @Test + fun `strips paths, ports kept explicit, and never leaks credentials`() { + whenever(hostService.list()).thenReturn( + listOf(host("https://user:secret@plugins.example.com:8443/plugin-root/api")) + ) + + val response = resource.getHostOrigins() + + assertThat(response.body).containsExactly("https://plugins.example.com:8443") + } + + @Test + fun `drops unparseable base URLs instead of failing the response`() { + whenever(hostService.list()).thenReturn( + listOf( + host("not a url at all"), + host("https://valid.example.com"), + ) + ) + + val response = resource.getHostOrigins() + + assertThat(response.body).containsExactly("https://valid.example.com") + } + + @Test + fun `returns an empty list when no hosts are registered`() { + whenever(hostService.list()).thenReturn(emptyList()) + + val response = resource.getHostOrigins() + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).isEmpty() + } + + private fun host(baseUrl: String) = ExternalPluginHost( + id = UUID.randomUUID(), + name = "host", + baseUrl = baseUrl, + secret = "encrypted-secret", + status = ExternalPluginHostStatus.CONNECTED, + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadCompatibilityTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadCompatibilityTest.kt new file mode 100644 index 0000000000..d1fee9301f --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadCompatibilityTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker +import com.ritense.externalplugin.compatibility.GzacVersionProvider +import com.ritense.externalplugin.compatibility.PluginPackageInspector +import com.ritense.externalplugin.service.EndpointDescriptionService +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginDiscoveryService +import com.ritense.externalplugin.service.ExternalPluginHostService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.core.env.Environment +import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Verifies the upload endpoint's compatibility gate: an incompatible package is rejected with a + * 409 (and never reaches the host) unless the operator forces it, while compatible packages and + * packages without a compatibility declaration upload straight through. + */ +class ExternalPluginUploadCompatibilityTest { + + private lateinit var hostService: ExternalPluginHostService + private lateinit var discoveryService: ExternalPluginDiscoveryService + private lateinit var objectMapper: ObjectMapper + private lateinit var resource: ExternalPluginManagementResource + + private val hostId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + hostService = mock() + discoveryService = mock() + objectMapper = ObjectMapper() + whenever(hostService.uploadPlugin(any(), any(), any(), any())) + .thenReturn(objectMapper.createObjectNode().put("pluginId", "x") as JsonNode) + + resource = ExternalPluginManagementResource( + hostService, + mock(), + mock(), + mock(), + mock(), + discoveryService, + mock(), + GzacCompatibilityChecker(GzacVersionProvider { "13.1.3" }), + PluginPackageInspector(objectMapper), + objectMapper, + ) + } + + @Test + fun `rejects a plugin that needs a newer GZAC with 409 and does not upload to the host`() { + val file = pluginZip("""{"compatibility":{"minGzacVersion":"14.0.0"}}""") + + val response = resource.uploadPlugin(hostId, file, force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CONFLICT) + assertThat(response.body!!.get("incompatible").asBoolean()).isTrue() + assertThat(response.body!!.get("currentGzacVersion").asText()).isEqualTo("13.1.3") + assertThat(response.body!!.get("minGzacVersion").asText()).isEqualTo("14.0.0") + verify(hostService, never()).uploadPlugin(any(), any(), any(), any()) + verify(discoveryService, never()).discoverAll() + } + + @Test + fun `rejects a plugin whose maximum is below the running GZAC`() { + val file = pluginZip("""{"compatibility":{"minGzacVersion":"12.0.0","maxGzacVersion":"12.1.0"}}""") + + val response = resource.uploadPlugin(hostId, file, force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CONFLICT) + assertThat(response.body!!.get("incompatible").asBoolean()).isTrue() + assertThat(response.body!!.get("maxGzacVersion").asText()).isEqualTo("12.1.0") + verify(hostService, never()).uploadPlugin(any(), any(), any(), any()) + } + + @Test + fun `uploads an incompatible plugin when forced`() { + val file = pluginZip("""{"compatibility":{"minGzacVersion":"14.0.0"}}""") + + val response = resource.uploadPlugin(hostId, file, force = true) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED) + verify(hostService).uploadPlugin(any(), any(), any(), any()) + verify(discoveryService).discoverAll() + } + + @Test + fun `uploads a compatible plugin straight through`() { + val file = pluginZip("""{"compatibility":{"minGzacVersion":"12.0.0"}}""") + + val response = resource.uploadPlugin(hostId, file, force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED) + verify(hostService).uploadPlugin(any(), any(), any(), any()) + } + + @Test + fun `uploads a plugin without a compatibility declaration`() { + val file = pluginZip("""{"pluginId":"x","version":"1.0.0"}""") + + val response = resource.uploadPlugin(hostId, file, force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED) + verify(hostService).uploadPlugin(any(), any(), any(), any()) + } + + private fun pluginZip(manifestJson: String): MockMultipartFile { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zos -> + zos.putNextEntry(ZipEntry("manifest.json")) + zos.write(manifestJson.toByteArray()) + zos.closeEntry() + } + return MockMultipartFile("file", "plugin.zip", "application/zip", out.toByteArray()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadOverwriteTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadOverwriteTest.kt new file mode 100644 index 0000000000..6c3c4c431b --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUploadOverwriteTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker +import com.ritense.externalplugin.compatibility.GzacVersionProvider +import com.ritense.externalplugin.compatibility.PluginPackageInspector +import com.ritense.externalplugin.service.EndpointDescriptionService +import com.ritense.externalplugin.service.ExternalPluginConfigurationService +import com.ritense.externalplugin.service.ExternalPluginDefinitionService +import com.ritense.externalplugin.service.ExternalPluginDiscoveryService +import com.ritense.externalplugin.service.ExternalPluginHostService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.core.env.Environment +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile +import org.springframework.web.client.HttpClientErrorException +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Verifies the upload endpoint's duplicate-version flow: a host `PLUGIN_VERSION_EXISTS` 409 is + * enriched with the uploaded manifest's requested permissions (for the UI's re-review screen), + * and a confirmed `overwrite=true` upload applies the approved overwrite — pin + re-grant — via + * [ExternalPluginConfigurationService.applyApprovedOverwrite]. + */ +class ExternalPluginUploadOverwriteTest { + + private lateinit var hostService: ExternalPluginHostService + private lateinit var configurationService: ExternalPluginConfigurationService + private lateinit var discoveryService: ExternalPluginDiscoveryService + private lateinit var objectMapper: ObjectMapper + private lateinit var resource: ExternalPluginManagementResource + + private val hostId = UUID.randomUUID() + + private val manifestJson = """ + { + "pluginId": "case-summary", + "version": "0.1.0", + "eventSubscriptions": ["com.ritense.valtimo.document.created"], + "permissions": { + "endpoints": [{"method": "GET", "pattern": "/api/v1/document/*"}], + "capabilities": ["gzac_api", "log"] + } + } + """.trimIndent() + + @BeforeEach + fun setUp() { + hostService = mock() + configurationService = mock() + discoveryService = mock() + objectMapper = ObjectMapper() + resource = ExternalPluginManagementResource( + hostService, + mock(), + configurationService, + mock(), + mock(), + discoveryService, + mock(), + GzacCompatibilityChecker(GzacVersionProvider { "13.1.3" }), + PluginPackageInspector(objectMapper), + objectMapper, + ) + } + + @Test + fun `enriches a host version-exists 409 with hashes and the requested permissions`() { + whenever(hostService.uploadPlugin(any(), any(), any(), any())).thenThrow( + versionExistsException(currentContentHash = "sha256:current", uploadedContentHash = "sha256:uploaded") + ) + + val response = resource.uploadPlugin(hostId, pluginZip(manifestJson), force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CONFLICT) + val body = response.body!! + assertThat(body.get("code").asText()).isEqualTo("PLUGIN_VERSION_EXISTS") + assertThat(body.get("pluginId").asText()).isEqualTo("case-summary") + assertThat(body.get("version").asText()).isEqualTo("0.1.0") + assertThat(body.get("currentContentHash").asText()).isEqualTo("sha256:current") + assertThat(body.get("uploadedContentHash").asText()).isEqualTo("sha256:uploaded") + assertThat(body.get("requestedEndpoints").single().get("pattern").asText()) + .isEqualTo("/api/v1/document/*") + assertThat(body.get("requestedEventSubscriptions").map { it.asText() }) + .containsExactly("com.ritense.valtimo.document.created") + assertThat(body.get("requestedCapabilities").map { it.asText() }) + .containsExactly("gzac_api", "log") + verify(discoveryService, never()).discoverAll() + verify(configurationService, never()).applyApprovedOverwrite(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `a confirmed overwrite pins the new content and re-grants before discovery runs`() { + whenever(hostService.uploadPlugin(any(), any(), any(), eq(true))).thenReturn( + objectMapper.readTree( + """{"pluginId": "case-summary", "version": "0.1.0", "contentHash": "sha256:new"}""" + ) as JsonNode + ) + + val response = resource.uploadPlugin(hostId, pluginZip(manifestJson), force = true, overwrite = true) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED) + val manifestCaptor = argumentCaptor() + verify(configurationService).applyApprovedOverwrite( + eq("case-summary"), + eq("0.1.0"), + eq("sha256:new"), + manifestCaptor.capture(), + ) + assertThat(manifestCaptor.firstValue.get("pluginId").asText()).isEqualTo("case-summary") + verify(discoveryService).discoverAll() + } + + @Test + fun `a plain upload does not apply any overwrite approval`() { + whenever(hostService.uploadPlugin(any(), any(), any(), eq(false))).thenReturn( + objectMapper.readTree("""{"pluginId": "case-summary", "version": "0.2.0"}""") as JsonNode + ) + + val response = resource.uploadPlugin(hostId, pluginZip(manifestJson), force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED) + verify(configurationService, never()).applyApprovedOverwrite(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `a non-version-exists host 409 stays a relayed error body`() { + whenever(hostService.uploadPlugin(any(), any(), any(), any())).thenThrow( + HttpClientErrorException.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + """{"error": "something else"}""".toByteArray(), + null, + ) + ) + + val response = resource.uploadPlugin(hostId, pluginZip(manifestJson), force = false) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CONFLICT) + assertThat(response.body!!.get("error").asText()).isEqualTo("Plugin host rejected the upload") + assertThat(response.body!!.has("code")).isFalse() + } + + private fun versionExistsException( + currentContentHash: String, + uploadedContentHash: String, + ): HttpClientErrorException = HttpClientErrorException.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + """ + { + "code": "PLUGIN_VERSION_EXISTS", + "error": "Plugin version already exists: case-summary@0.1.0", + "message": "This version already exists on the host.", + "currentContentHash": "$currentContentHash", + "uploadedContentHash": "$uploadedContentHash" + } + """.trimIndent().toByteArray(), + null, + ) + + private fun pluginZip(manifest: String): MockMultipartFile { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zos -> + zos.putNextEntry(ZipEntry("manifest.json")) + zos.write(manifest.toByteArray()) + zos.closeEntry() + } + return MockMultipartFile("file", "plugin.zip", "application/zip", out.toByteArray()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResourceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResourceTest.kt new file mode 100644 index 0000000000..73eeaea64c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenIntrospectionResourceTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.externalplugin.security.ExternalPluginServicePrincipal +import com.ritense.externalplugin.security.ExternalPluginUserPrincipal +import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider +import com.ritense.externalplugin.service.ExternalPluginUserTokenService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.server.ResponseStatusException +import java.time.temporal.ChronoUnit +import java.util.UUID + +class ExternalPluginUserTokenIntrospectionResourceTest { + + private val keyProvider = ExternalPluginUserTokenKeyProvider("introspection-test-secret") + private val userTokenService = ExternalPluginUserTokenService(keyProvider) + private val resource = ExternalPluginUserTokenIntrospectionResource(keyProvider) + + private val configurationId: UUID = UUID.randomUUID() + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + fun `returns the token's subject, configuration id and expiry for a user-token principal`() { + val issued = userTokenService.issue("john@example.com", listOf("ROLE_USER"), configurationId, 0) + val principal = ExternalPluginUserPrincipal("john@example.com", listOf("ROLE_USER"), configurationId) + // Mirrors ExternalPluginUserTokenAuthenticator: the raw JWT rides along as credentials. + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, issued.token, principal.authorities) + + val response = resource.introspect() + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + val body = response.body!! + assertThat(body.subject).isEqualTo("john@example.com") + assertThat(body.configurationId).isEqualTo(configurationId) + // JWT `exp` has second precision — compare after truncating the issued instant. + assertThat(body.expiresAt).isEqualTo(issued.expiresAt.truncatedTo(ChronoUnit.SECONDS)) + } + + @Test + fun `rejects a regular authenticated user with 403`() { + SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken( + "jane@example.com", + "n/a", + listOf(SimpleGrantedAuthority("ROLE_USER")), + ) + + assertThatThrownBy { resource.introspect() } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `rejects a plugin service-token principal with 403`() { + val principal = ExternalPluginServicePrincipal(configurationId, "case-summary", "0.1.0") + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, "service-token", emptyList()) + + assertThatThrownBy { resource.introspect() } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `rejects an unauthenticated caller with 403`() { + SecurityContextHolder.clearContext() + + assertThatThrownBy { resource.introspect() } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.FORBIDDEN) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResourceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResourceTest.kt new file mode 100644 index 0000000000..ac4ffb0283 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/web/rest/ExternalPluginUserTokenResourceTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin.web.rest + +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginGrantedEndpoint +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginGrantedEndpointRepository +import com.ritense.externalplugin.service.ExternalPluginUserTokenService +import com.ritense.externalplugin.service.IssuedUserToken +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class ExternalPluginUserTokenResourceTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var grantedEndpointRepository: ExternalPluginGrantedEndpointRepository + private lateinit var userTokenService: ExternalPluginUserTokenService + private lateinit var resource: ExternalPluginUserTokenResource + + private val configurationId: UUID = UUID.randomUUID() + private val definitionId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + grantedEndpointRepository = mock() + userTokenService = mock() + resource = ExternalPluginUserTokenResource( + configurationRepository, + definitionRepository, + grantedEndpointRepository, + userTokenService, + ) + + SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken( + "john@example.com", + "n/a", + listOf(SimpleGrantedAuthority("ROLE_USER")), + ) + } + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + } + + @Test + fun `mints a token and returns the configuration's granted endpoints alongside it`() { + val expiresAt = Instant.now().plusSeconds(900) + stubConfiguration(tokenGeneration = 3) + stubDefinition() + whenever( + userTokenService.issue(eq("john@example.com"), eq(listOf("ROLE_USER")), eq(configurationId), eq(3L)) + ).thenReturn(IssuedUserToken("token-value", expiresAt)) + whenever(grantedEndpointRepository.findAllByConfigurationId(configurationId)).thenReturn( + listOf( + grantedEndpoint("GET", "/api/v1/documents/**"), + grantedEndpoint("POST", "/api/v1/process/*/start"), + ) + ) + + val response = resource.mintUserToken(configurationId) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + val body = response.body!! + assertThat(body.userToken).isEqualTo("token-value") + assertThat(body.expiresAt).isEqualTo(expiresAt) + assertThat(body.grantedEndpoints).containsExactly( + ExternalPluginUserTokenResource.GrantedEndpointDto("GET", "/api/v1/documents/**"), + ExternalPluginUserTokenResource.GrantedEndpointDto("POST", "/api/v1/process/*/start"), + ) + // The minted token is bound to the configuration's *current* generation, so a later + // revoke-tokens bump invalidates it. + verify(userTokenService).issue(eq("john@example.com"), eq(listOf("ROLE_USER")), eq(configurationId), eq(3L)) + } + + @Test + fun `returns an empty granted-endpoint list for a configuration that grants nothing`() { + // An empty list must be surfaced as such (not omitted): the frontend precheck treats an + // empty allowlist as deny-all, mirroring the server-side allowlist filter. + stubConfiguration() + stubDefinition() + whenever(userTokenService.issue(any(), any(), any(), any())) + .thenReturn(IssuedUserToken("token-value", Instant.now().plusSeconds(900))) + whenever(grantedEndpointRepository.findAllByConfigurationId(configurationId)).thenReturn(emptyList()) + + val response = resource.mintUserToken(configurationId) + + assertThat(response.body!!.grantedEndpoints).isEmpty() + } + + @Test + fun `rejects an unknown configuration with 404 and does not mint`() { + whenever(configurationRepository.findById(configurationId)).thenReturn(Optional.empty()) + + assertThatThrownBy { resource.mintUserToken(configurationId) } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.NOT_FOUND) + verify(userTokenService, never()).issue(any(), any(), any(), any()) + } + + @Test + fun `rejects an unauthenticated caller with 401 and does not mint`() { + SecurityContextHolder.clearContext() + + assertThatThrownBy { resource.mintUserToken(configurationId) } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.UNAUTHORIZED) + verify(userTokenService, never()).issue(any(), any(), any(), any()) + } + + @Test + fun `refuses to mint with 409 while the plugin's changed content awaits re-acceptance`() { + stubConfiguration() + stubDefinition(pendingContentHash = "sha256:changed") + + assertThatThrownBy { resource.mintUserToken(configurationId) } + .isInstanceOf(ResponseStatusException::class.java) + .extracting { (it as ResponseStatusException).statusCode } + .isEqualTo(HttpStatus.CONFLICT) + verify(userTokenService, never()).issue(any(), any(), any(), any()) + } + + private fun stubConfiguration(tokenGeneration: Long = 0) { + whenever(configurationRepository.findById(configurationId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configurationId, + definitionId = definitionId, + title = "Config", + tokenGeneration = tokenGeneration, + ) + ) + ) + } + + private fun stubDefinition(pendingContentHash: String? = null) { + whenever(definitionRepository.findById(definitionId)).thenReturn( + Optional.of( + ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + contentHash = "sha256:accepted", + pendingContentHash = pendingContentHash, + ) + ) + ) + } + + private fun grantedEndpoint(method: String, pattern: String) = ExternalPluginGrantedEndpoint( + id = UUID.randomUUID(), + configurationId = configurationId, + httpMethod = method, + endpointPattern = pattern, + ) +} diff --git a/backend/external-plugin/src/test/resources/config/application-mysql.yml b/backend/external-plugin/src/test/resources/config/application-mysql.yml new file mode 100644 index 0000000000..8408c1a47f --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application-mysql.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3368/external-plugin-test + username: valtimo + password: password + hikari: + auto-commit: false + data-source-properties: + serverTimezone: UTC + jpa: + database-platform: org.hibernate.dialect.MySQL8Dialect + database: mysql + +valtimo: + database: mysql diff --git a/backend/external-plugin/src/test/resources/config/application-postgresql.yml b/backend/external-plugin/src/test/resources/config/application-postgresql.yml new file mode 100644 index 0000000000..7c8438cbc9 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application-postgresql.yml @@ -0,0 +1,14 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:3368/external-plugin-test + username: valtimo + password: password + hikari: + auto-commit: false + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + database: postgresql + +valtimo: + database: postgres diff --git a/backend/external-plugin/src/test/resources/config/application.yml b/backend/external-plugin/src/test/resources/config/application.yml new file mode 100644 index 0000000000..7a4102bdd2 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application.yml @@ -0,0 +1,48 @@ +spring: + # Several modules are on the test classpath only so EndpointDescriptionCoverageTest can scan + # their controllers, but their auto-configurations cannot start in this module's IT context: + # mandrill re-registers the mail JPA repositories that MailAutoConfiguration already provides + # (a duplicate the strict, no-override test context rejects), and the Exact plugin requires + # exact.baseUrl/exact.redirectUrl properties this module doesn't define. + autoconfigure: + exclude: + - com.ritense.mail.autoconfigure.MandrillMailAutoConfiguration + - com.ritense.exact.config.ExactPluginAutoConfiguration + datasource: + type: com.zaxxer.hikari.HikariDataSource + liquibase: + enabled: false + jpa: + show_sql: true + open-in-view: false + properties: + hibernate: + hbm2ddl.auto: none + generate_statistics: false + naming-strategy: org.springframework.boot.orm.jpa.hibernate.SpringNamingStrategy + cache: + use_second_level_cache: false + use_query_cache: false + region.factory_class: org.hibernate.cache.ehcache.SingletonEhCacheRegionFactory + format_sql: true + jdbc: + time_zone: UTC + connection: + provider_disables_autocommit: true + hibernate: + ddl-auto: none + +valtimo: + plugin: + encryption-secret: "abcdefghijklmnop" + +spring-actuator: + username: admin + password: password + +operaton: + bpm: + history-level: audit + generic-properties: + properties: + enforceHistoryTimeToLive: false diff --git a/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn new file mode 100644 index 0000000000..b081dbd60d --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn @@ -0,0 +1,39 @@ + + + + + + SequenceFlow_1abb79g + + + SequenceFlow_0j10547 + + + + + SequenceFlow_1abb79g + SequenceFlow_0j10547 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json new file mode 100644 index 0000000000..87e2c08527 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json @@ -0,0 +1,7 @@ +{ + "key": "autodeploy", + "name": "Autodeploy", + "versionTag": "1.0.0", + "canHaveAssignee": true, + "autoAssignTasks": true +} diff --git a/backend/external-plugin/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/backend/external-plugin/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/backend/external-plugin/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowManagementResource.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowManagementResource.kt index 358a1d3a55..8ed84ee6c3 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowManagementResource.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowManagementResource.kt @@ -24,6 +24,7 @@ import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionChecker import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.core.io.ClassPathResource import org.springframework.core.io.Resource @@ -49,10 +50,18 @@ class FormFlowManagementResource( private val formFlowService: FormFlowService, private val caseDefinitionChecker: CaseDefinitionChecker, ) { + @EndpointDescription( + en = "Get form flow definition schema", + nl = "Schema van form flow definitie ophalen", + ) @GetMapping("/v1/form-flow-definition/schema") fun getFormFlowDefinitionSchema(): ResponseEntity = ResponseEntity.ok(ClassPathResource(FORM_FLOW_SCHEMA_PATH)) + @EndpointDescription( + en = "List form flow definitions", + nl = "Form flow definities ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition") @Transactional fun getAllFormFlowDefinitions( @@ -67,6 +76,10 @@ class FormFlowManagementResource( return ResponseEntity.ok(definitions) } + @EndpointDescription( + en = "Get form flow definition by key", + nl = "Form flow definitie ophalen op sleutel", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun getFormFlowDefinitionById( @@ -80,6 +93,10 @@ class FormFlowManagementResource( return ResponseEntity.ok(FormFlowDefinitionDto.of(definition, readOnly)) } + @EndpointDescription( + en = "Delete form flow definition", + nl = "Form flow definitie verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun deleteFormFlowDefinition( @@ -95,6 +112,10 @@ class FormFlowManagementResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Create form flow definition", + nl = "Form flow definitie aanmaken", + ) @PostMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition") @Transactional fun createFormFlowDefinition( @@ -111,6 +132,10 @@ class FormFlowManagementResource( return ResponseEntity.ok(FormFlowDefinitionDto.of(newDefinition, false)) } + @EndpointDescription( + en = "Update form flow definition", + nl = "Form flow definitie bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/{definitionKey}") @Transactional fun updateFormFlowDefinition( diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowResource.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowResource.kt index 20ee3b9dea..ba3d59f6dd 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowResource.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/FormFlowResource.kt @@ -25,6 +25,7 @@ import com.ritense.formflow.service.FormFlowService import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.formflow.service.FormFlowValtimoService import com.ritense.formflow.web.rest.dto.FormFlowBreadcrumbsResponse import com.ritense.formflow.web.rest.result.CompleteStepResult @@ -47,6 +48,10 @@ class FormFlowResource( private val formFlowService: FormFlowService, private val formFlowValtimoService: FormFlowValtimoService, ) { + @EndpointDescription( + en = "Get form flow state", + nl = "Form flow status ophalen", + ) @GetMapping( value = [ "/v1/form-flow/instance/{formFlowInstanceId}", @@ -70,6 +75,10 @@ class FormFlowResource( return ResponseEntity.ok(GetFormFlowStateResult(instance.id.id, toStepResult(stepInstance), toResultList(openResult))) } + @EndpointDescription( + en = "Complete form flow step", + nl = "Form flow stap voltooien", + ) @PostMapping( value = [ "/v1/form-flow/instance/{formFlowId}/step/instance/{stepInstanceId}", @@ -97,6 +106,10 @@ class FormFlowResource( return ResponseEntity.ok(CompleteStepResult(instance.id.id, nextStep?.let { toStepResult(it) }, onOpenResult, onCompleteResult)) } + @EndpointDescription( + en = "Navigate to previous form flow step", + nl = "Naar vorige form flow stap navigeren", + ) @PostMapping( value = [ "/v1/form-flow/instance/{formFlowId}/back", @@ -120,6 +133,10 @@ class FormFlowResource( return ResponseEntity.ok(GetFormFlowStateResult(instance.id.id, stepInstance?.let { toStepResult(it) }, onOpenResult = openResult)) } + @EndpointDescription( + en = "Save form flow step temporarily", + nl = "Form flow stap tussentijds opslaan", + ) @PostMapping( value = [ "/v1/form-flow/instance/{formFlowId}/save", @@ -139,6 +156,10 @@ class FormFlowResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Navigate to specific form flow step", + nl = "Naar specifieke form flow stap navigeren", + ) @PostMapping("/v1/form-flow/instance/{formFlowId}/step/instance/{stepInstanceId}/to/step/instance/{targetStepInstanceId}") @Transactional fun navigateToStep( @@ -163,6 +184,10 @@ class FormFlowResource( return ResponseEntity.ok(GetFormFlowStateResult(instance.id.id, toStepResult(stepInstance), openResult)) } + @EndpointDescription( + en = "Get form flow breadcrumbs", + nl = "Form flow kruimelpad ophalen", + ) @GetMapping("/v1/form-flow/instance/{formFlowId}/breadcrumbs") @Transactional fun getBreadcrumbs( diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/ProcessLinkFormFlowDefinitionResource.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/ProcessLinkFormFlowDefinitionResource.kt index c99e7a007c..37808207dd 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/ProcessLinkFormFlowDefinitionResource.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/web/rest/ProcessLinkFormFlowDefinitionResource.kt @@ -20,6 +20,7 @@ import com.ritense.formflow.service.FormFlowService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -33,6 +34,10 @@ class ProcessLinkFormFlowDefinitionResource( val formFlowService: FormFlowService ) { + @EndpointDescription( + en = "List form flow process link options", + nl = "Proceskoppelingsopties voor form flow ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/process-link-option") fun getFormLinkOptions( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/form-view-model/src/main/kotlin/com/ritense/formviewmodel/web/rest/FormViewModelResource.kt b/backend/form-view-model/src/main/kotlin/com/ritense/formviewmodel/web/rest/FormViewModelResource.kt index 62ee3fd628..ac14ae9525 100644 --- a/backend/form-view-model/src/main/kotlin/com/ritense/formviewmodel/web/rest/FormViewModelResource.kt +++ b/backend/form-view-model/src/main/kotlin/com/ritense/formviewmodel/web/rest/FormViewModelResource.kt @@ -23,6 +23,7 @@ import com.ritense.formviewmodel.viewmodel.ViewModel import com.ritense.formviewmodel.web.rest.dto.StartFormSubmissionResult import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.GetMapping @@ -42,6 +43,10 @@ class FormViewModelResource( private val formViewModelSubmissionService: FormViewModelSubmissionService ) { + @EndpointDescription( + en = "Get the start form view model", + nl = "Formulier-viewmodel voor het startformulier ophalen", + ) @GetMapping("/start-form") fun getStartFormViewModel( @RequestParam processDefinitionKey: String, @@ -58,6 +63,10 @@ class FormViewModelResource( } } + @EndpointDescription( + en = "Get the user task form view model", + nl = "Formulier-viewmodel voor de gebruikerstaak ophalen", + ) @GetMapping("/user-task") fun getUserTaskFormViewModel( @RequestParam taskInstanceId: String @@ -69,6 +78,10 @@ class FormViewModelResource( } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Update the start form view model", + nl = "Formulier-viewmodel voor het startformulier bijwerken", + ) @PostMapping("/start-form") fun updateStartFormViewModel( @RequestParam processDefinitionKey: String, @@ -86,6 +99,10 @@ class FormViewModelResource( } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Update the user task form view model", + nl = "Formulier-viewmodel voor de gebruikerstaak bijwerken", + ) @PostMapping("/user-task") fun updateUserTaskFormViewModel( @RequestParam taskInstanceId: String, @@ -101,6 +118,10 @@ class FormViewModelResource( } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Submit a user task form", + nl = "Inzending voor een gebruikerstaakformulier verwerken", + ) @PostMapping("/submit/user-task") fun submitTask( @RequestParam taskInstanceId: String, @@ -113,6 +134,10 @@ class FormViewModelResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Submit a start form", + nl = "Inzending voor een startformulier verwerken", + ) @PostMapping("/submit/start-form") fun submitStartForm( @RequestParam processDefinitionKey: String, diff --git a/backend/form/src/main/java/com/ritense/form/web/rest/impl/FormIoFormFileResource.java b/backend/form/src/main/java/com/ritense/form/web/rest/impl/FormIoFormFileResource.java index 2e71f8f62b..4a3bca92b7 100644 --- a/backend/form/src/main/java/com/ritense/form/web/rest/impl/FormIoFormFileResource.java +++ b/backend/form/src/main/java/com/ritense/form/web/rest/impl/FormIoFormFileResource.java @@ -19,6 +19,7 @@ import com.ritense.form.web.rest.FormFileResource; import com.ritense.logging.LoggableResource; import com.ritense.resource.service.ResourceService; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.contract.resource.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -39,6 +40,10 @@ public FormIoFormFileResource(ResourceService resourceService) { } @Override + @EndpointDescription( + en = "Upload form file", + nl = "Formulierbestand uploaden" + ) @PostMapping(value = "/v1/form-file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadFile( @LoggableResource(resourceTypeName = "jsonSchemaDocumentName") @RequestParam("documentDefinitionName") String documentDefinitionName, @@ -50,6 +55,10 @@ public ResponseEntity uploadFile( } @Override + @EndpointDescription( + en = "Get form file redirect", + nl = "Formulierbestand doorverwijzing ophalen" + ) @GetMapping("/v1/form-file") public RedirectView getFile(@RequestParam("form") String fileName) { return new RedirectView( @@ -60,6 +69,10 @@ public RedirectView getFile(@RequestParam("form") String fileName) { } @Override + @EndpointDescription( + en = "Delete form file", + nl = "Formulierbestand verwijderen" + ) @DeleteMapping("/v1/form-file") public ResponseEntity deleteFile(@RequestParam("form") String fileName) { resourceService.removeResource(stripInitialSlashFromPath(fileName)); diff --git a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormManagementResource.kt b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormManagementResource.kt index 08f3801e83..dabb5522ba 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormManagementResource.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormManagementResource.kt @@ -23,6 +23,7 @@ import com.ritense.form.service.FormDefinitionService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -48,6 +49,10 @@ import java.util.UUID class FormManagementResource( private val formDefinitionService: FormDefinitionService, ) { + @EndpointDescription( + en = "Query form definitions for case definition", + nl = "Formulierdefinities voor dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form") fun getFormDefinitions( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -64,6 +69,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Get form definition by id for case definition", + nl = "Formulierdefinitie op id voor dossierdefinitie ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form/{formDefinitionId}") fun getFormDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -79,6 +88,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Create form definition for case definition", + nl = "Formulierdefinitie voor dossierdefinitie aanmaken", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form") fun createFormDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -93,6 +106,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Update form definition for case definition", + nl = "Formulierdefinitie voor dossierdefinitie bijwerken", + ) @PutMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form") fun updateFormDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -107,6 +124,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Delete form definition for case definition", + nl = "Formulierdefinitie voor dossierdefinitie verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form/{formDefinitionId}") fun deleteFormDefinition( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -120,6 +141,10 @@ class FormManagementResource( return ResponseEntity.noContent().build(); } + @EndpointDescription( + en = "Check form definition name exists for case definition", + nl = "Controleren of formulierdefinitienaam bestaat voor dossierdefinitie", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form/{name}/exists") fun formDefinitionExists( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -134,6 +159,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "List or search form definitions", + nl = "Formulierdefinities ophalen of zoeken", + ) @GetMapping("/management/v1/form") fun getFormDefinitions( @RequestParam(required = false) searchTerm: String?, @@ -146,6 +175,10 @@ class FormManagementResource( } } + @EndpointDescription( + en = "Check form definition name exists", + nl = "Controleren of formulierdefinitienaam bestaat", + ) @GetMapping("/management/v1/form/exists/{name}") fun formDefinitionExists( @PathVariable("name") formName: String, @@ -157,6 +190,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Create form definition", + nl = "Formulierdefinitie aanmaken", + ) @PostMapping("/management/v1/form") fun createFormDefinition( @Valid @RequestBody formDefinition: CreateFormDefinitionRequest, @@ -168,6 +205,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Get form definition by id", + nl = "Formulierdefinitie op id ophalen", + ) @GetMapping("/management/v1/form/{formDefinitionId}") fun getFormDefinition( @PathVariable("formDefinitionId") formDefinitionId: String, @@ -180,6 +221,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Update form definition", + nl = "Formulierdefinitie bijwerken", + ) @PutMapping("/management/v1/form") fun updateFormDefinition( @Valid @RequestBody formDefinition: ModifyFormDefinitionRequest, @@ -191,6 +236,10 @@ class FormManagementResource( ) } + @EndpointDescription( + en = "Delete form definition", + nl = "Formulierdefinitie verwijderen", + ) @DeleteMapping("/management/v1/form/{formDefinitionId}") fun deleteFormDefinition( @PathVariable("formDefinitionId") formDefinitionId: String, diff --git a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormOptionResource.kt b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormOptionResource.kt index e48a12483a..1b79287caa 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormOptionResource.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormOptionResource.kt @@ -21,6 +21,7 @@ import com.ritense.form.web.rest.dto.FormOption import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -34,11 +35,19 @@ class FormOptionResource( private val formDefinitionService: FormDefinitionService, ) { + @EndpointDescription( + en = "List unlinked form options", + nl = "Niet-gekoppelde formulieropties ophalen", + ) @GetMapping("/v1/form-option") fun getFormDefinitions(): ResponseEntity> { return ResponseEntity.ok(formDefinitionService.getUnlinkedFormOptions()) } + @EndpointDescription( + en = "List form options for case definition", + nl = "Formulieropties voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-option") fun getFormDefinitions( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormResource.kt b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormResource.kt index 0e4f95e755..ba809f5b6c 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormResource.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/web/rest/FormResource.kt @@ -28,6 +28,7 @@ import com.ritense.processlink.domain.ProcessLink import com.ritense.valtimo.operaton.domain.OperatonTask import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -49,6 +50,10 @@ class FormResource( private val formDefinitionService: FormDefinitionService, ) { + @EndpointDescription( + en = "Handle process link form submission", + nl = "Formulierinzending voor proceslink verwerken", + ) @PostMapping("/v1/process-link/{processLinkId}/form/submission") fun handleSubmission( @LoggableResource(resourceType = ProcessLink::class) @PathVariable processLinkId: UUID, @@ -67,6 +72,10 @@ class FormResource( ) ) + @EndpointDescription( + en = "Get prefilled form definition by form key", + nl = "Vooringevulde formulierdefinitie ophalen op formuliersleutel", + ) @GetMapping("/v1/process-link/form-definition/{formKey}") fun getFormDefinitionByFormKey( @PathVariable formKey: String, diff --git a/backend/form/src/main/kotlin/com/ritense/form/web/rest/IntermediateSubmissionResource.kt b/backend/form/src/main/kotlin/com/ritense/form/web/rest/IntermediateSubmissionResource.kt index f89ca93d6f..e85917cdc9 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/web/rest/IntermediateSubmissionResource.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/web/rest/IntermediateSubmissionResource.kt @@ -25,6 +25,7 @@ import com.ritense.logging.withLoggingContext import com.ritense.valtimo.operaton.domain.OperatonTask import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -42,6 +43,10 @@ class IntermediateSubmissionResource( private val intermediateSubmissionService: IntermediateSubmissionService ) { + @EndpointDescription( + en = "Get intermediate submission", + nl = "Tussentijdse inzending ophalen", + ) @GetMapping fun getIntermediateSubmission( @LoggableResource(resourceType = OperatonTask::class) @RequestParam taskInstanceId: String @@ -50,6 +55,10 @@ class IntermediateSubmissionResource( return intermediateSubmission?.let { ResponseEntity.ok(it.toResponse()) } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Store intermediate submission", + nl = "Tussentijdse inzending opslaan", + ) @PostMapping fun storeIntermediateSubmission( @Valid @RequestBody request: IntermediateSaveRequest @@ -63,6 +72,10 @@ class IntermediateSubmissionResource( } } + @EndpointDescription( + en = "Clear intermediate submission", + nl = "Tussentijdse inzending verwijderen", + ) @DeleteMapping fun clearIntermediateSubmission( @LoggableResource(resourceType = OperatonTask::class) @RequestParam taskInstanceId: String diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/autoconfigure/IkoAutoConfiguration.kt b/backend/iko/src/main/kotlin/com/ritense/iko/autoconfigure/IkoAutoConfiguration.kt index faed11f833..c3b6888d25 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/autoconfigure/IkoAutoConfiguration.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/autoconfigure/IkoAutoConfiguration.kt @@ -576,5 +576,4 @@ class IkoAutoConfiguration { ikoWidgetService, ) } - } diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoListColumnManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoListColumnManagementResource.kt index 9fafeb5025..9e53f6b233 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoListColumnManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoListColumnManagementResource.kt @@ -23,6 +23,7 @@ import com.ritense.iko.web.rest.request.IkoListColumnUpdateRequest import com.ritense.search.importer.ListColumnDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -42,6 +43,10 @@ class IkoListColumnManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO list columns for management", + nl = "IKO-lijstkolommen ophalen voor beheer", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/column") fun getIkoListColumnsForManagement( @PathVariable ikoViewKey: String, @@ -53,6 +58,10 @@ class IkoListColumnManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO list column by key", + nl = "IKO-lijstkolom ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/column/{key}") fun getIkoListColumn( @PathVariable ikoViewKey: String, @@ -63,6 +72,10 @@ class IkoListColumnManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO list column", + nl = "IKO-lijstkolom aanmaken", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/column/{key}") fun createIkoListColumn( @PathVariable ikoViewKey: String, @@ -80,6 +93,10 @@ class IkoListColumnManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO list column", + nl = "IKO-lijstkolom bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/column/{key}") fun updateIkoListColumn( @PathVariable ikoViewKey: String, @@ -97,6 +114,10 @@ class IkoListColumnManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO list columns order", + nl = "Volgorde van IKO-lijstkolommen bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/column") fun updateIkoListColumnsOrder( @PathVariable ikoViewKey: String, @@ -117,6 +138,10 @@ class IkoListColumnManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO list column", + nl = "IKO-lijstkolom verwijderen", + ) @DeleteMapping("/v1/iko-view/{ikoViewKey}/column/{key}") fun deleteIkoListColumn( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoRepositoryManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoRepositoryManagementResource.kt index 7cda2b7067..1c54d821c9 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoRepositoryManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoRepositoryManagementResource.kt @@ -23,6 +23,7 @@ import com.ritense.iko.web.rest.request.IkoRepositoryConfigUpdateRequest import com.ritense.iko.web.rest.response.IkoRepositoryConfigResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.iko.PropertyField import jakarta.validation.Valid import org.springframework.data.domain.Page @@ -48,12 +49,20 @@ class IkoRepositoryManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO repository types", + nl = "IKO-registertypen ophalen", + ) @GetMapping("/v1/iko-types") fun getIkoRepositoriesTypes(): ResponseEntity> { return ResponseEntity.ok(service.getIkoRepositoryTypes()) } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO repository config property fields by type", + nl = "Eigenschapsvelden van IKO-registerconfiguratie ophalen per type", + ) @GetMapping("/v1/iko-property-fields/{type}/repository-config") fun getIkoRepositoryConfigPropertyFields( @PathVariable type: String, @@ -62,6 +71,10 @@ class IkoRepositoryManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO repository configs for management", + nl = "IKO-registerconfiguraties ophalen voor beheer", + ) @GetMapping("/v1/iko") fun getIkoRepositoryConfigsForManagement( @RequestParam title: String?, @@ -77,6 +90,10 @@ class IkoRepositoryManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO repository config by key", + nl = "IKO-registerconfiguratie ophalen op sleutel", + ) @GetMapping("/v1/iko/{key}") fun getIkoRepositoryConfig( @PathVariable key: String, @@ -86,6 +103,10 @@ class IkoRepositoryManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO repository config", + nl = "IKO-registerconfiguratie aanmaken", + ) @PostMapping("/v1/iko/{key}") fun createIkoRepositoryConfig( @PathVariable key: String, @@ -101,6 +122,10 @@ class IkoRepositoryManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO repository config", + nl = "IKO-registerconfiguratie bijwerken", + ) @PutMapping("/v1/iko/{key}") fun updateIkoRepositoryConfig( @PathVariable key: String, @@ -116,6 +141,10 @@ class IkoRepositoryManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO repository config", + nl = "IKO-registerconfiguratie verwijderen", + ) @DeleteMapping("/v1/iko/{key}") fun deleteIkoRepositoryConfig( @PathVariable key: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionManagementResource.kt index 8059630e5f..90edd2bc21 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionManagementResource.kt @@ -24,6 +24,7 @@ import com.ritense.iko.web.rest.request.IkoSearchActionUpdateRequest import com.ritense.iko.web.rest.response.IkoSearchActionResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.iko.PropertyField import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -45,6 +46,10 @@ class IkoSearchActionManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO search action property fields by type", + nl = "Eigenschapsvelden van IKO-zoekactie ophalen per type", + ) @GetMapping("/v1/iko-property-fields/{type}/search-action") fun getIkoRepositoryConfigPropertyFields( @PathVariable type: String, @@ -53,6 +58,10 @@ class IkoSearchActionManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO search actions for management", + nl = "IKO-zoekacties ophalen voor beheer", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/search-action") fun getIkoSearchActionsForManagement( @PathVariable ikoViewKey: String, @@ -64,6 +73,10 @@ class IkoSearchActionManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO search action by key", + nl = "IKO-zoekactie ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/search-action/{key}") fun getIkoSearchAction( @PathVariable ikoViewKey: String, @@ -75,6 +88,10 @@ class IkoSearchActionManagementResource( @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO search action", + nl = "IKO-zoekactie aanmaken", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/search-action/{key}") fun createIkoSearchAction( @PathVariable ikoViewKey: String, @@ -92,6 +109,10 @@ class IkoSearchActionManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO search action", + nl = "IKO-zoekactie bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/search-action/{key}") fun updateIkoSearchAction( @PathVariable ikoViewKey: String, @@ -110,6 +131,10 @@ class IkoSearchActionManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO search actions order", + nl = "Volgorde van IKO-zoekacties bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/search-action") fun updateIkoSearchActionsOrder( @PathVariable ikoViewKey: String, @@ -129,6 +154,10 @@ class IkoSearchActionManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO search action", + nl = "IKO-zoekactie verwijderen", + ) @DeleteMapping("/v1/iko-view/{ikoViewKey}/search-action/{key}") fun deleteIkoSearchAction( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionResource.kt index 047794a80d..c1bc69ea4d 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchActionResource.kt @@ -26,6 +26,7 @@ import com.ritense.search.domain.SearchFieldV2 import com.ritense.search.domain.SearchListColumn import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.iko.DataFilter import jakarta.validation.Valid import org.springframework.data.domain.PageRequest @@ -48,6 +49,10 @@ class IkoSearchActionResource( private val ikoSearchFieldService: IkoSearchFieldService, ) { + @EndpointDescription( + en = "List IKO search actions", + nl = "IKO-zoekacties ophalen", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/search-action") fun getIkoSearchActions( @PathVariable ikoViewKey: String, @@ -63,6 +68,10 @@ class IkoSearchActionResource( return ResponseEntity.ok(response) } + @EndpointDescription( + en = "Execute IKO search action", + nl = "IKO-zoekactie uitvoeren", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search") fun search( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchFieldManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchFieldManagementResource.kt index 19d0f8f4b9..9e39400f4d 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchFieldManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoSearchFieldManagementResource.kt @@ -23,6 +23,7 @@ import com.ritense.iko.web.rest.request.IkoSearchFieldUpdateRequest import com.ritense.iko.web.rest.response.IkoSearchFieldResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -42,6 +43,10 @@ class IkoSearchFieldManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO search fields for management", + nl = "IKO-zoekvelden ophalen voor beheer", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field") fun getIkoSearchFieldsForManagement( @PathVariable ikoViewKey: String, @@ -55,6 +60,10 @@ class IkoSearchFieldManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO search field by key", + nl = "IKO-zoekveld ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field/{key}") fun getIkoSearchField( @PathVariable ikoViewKey: String, @@ -66,6 +75,10 @@ class IkoSearchFieldManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO search field", + nl = "IKO-zoekveld aanmaken", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field/{key}") fun createIkoSearchField( @PathVariable ikoViewKey: String, @@ -86,6 +99,10 @@ class IkoSearchFieldManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO search field", + nl = "IKO-zoekveld bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field/{key}") fun updateIkoSearchField( @PathVariable ikoViewKey: String, @@ -110,6 +127,10 @@ class IkoSearchFieldManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO search fields order", + nl = "Volgorde van IKO-zoekvelden bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field") fun updateIkoSearchFieldsOrder( @PathVariable ikoViewKey: String, @@ -133,6 +154,10 @@ class IkoSearchFieldManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO search field", + nl = "IKO-zoekveld verwijderen", + ) @DeleteMapping("/v1/iko-view/{ikoViewKey}/search-action/{ikoSearchActionKey}/search-field/{key}") fun deleteIkoSearchField( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabManagementResource.kt index f067879698..d2ece5c5f8 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabManagementResource.kt @@ -23,6 +23,7 @@ import com.ritense.iko.web.rest.request.IkoTabUpdateRequest import com.ritense.tab.web.rest.dto.TabDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.iko.PropertyField import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -43,6 +44,10 @@ class IkoTabManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO tab property fields by type", + nl = "Eigenschapsvelden van IKO-tabblad ophalen per type", + ) @GetMapping("/v1/iko-property-fields/{type}/tab") fun getIkoTabPropertyFields( @PathVariable type: String, @@ -51,6 +56,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO tabs for management", + nl = "IKO-tabbladen ophalen voor beheer", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab") fun getIkoTabsForManagement( @PathVariable ikoViewKey: String, @@ -62,6 +71,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO tab by key", + nl = "IKO-tabblad ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab/{key}") fun getIkoTab( @PathVariable ikoViewKey: String, @@ -72,6 +85,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO tab", + nl = "IKO-tabblad aanmaken", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/tab/{key}") fun createIkoTab( @PathVariable ikoViewKey: String, @@ -86,6 +103,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO tab", + nl = "IKO-tabblad bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/tab/{key}") fun updateIkoTab( @PathVariable ikoViewKey: String, @@ -104,6 +125,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO tabs order", + nl = "Volgorde van IKO-tabbladen bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/tab") fun updateIkoTabOrder( @PathVariable ikoViewKey: String, @@ -121,6 +146,10 @@ class IkoTabManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO tab", + nl = "IKO-tabblad verwijderen", + ) @DeleteMapping("/v1/iko-view/{ikoViewKey}/tab/{key}") fun deleteIkoTab( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabResource.kt index 6d0f540cd8..7ba67b431d 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoTabResource.kt @@ -20,6 +20,7 @@ import com.ritense.iko.service.IkoTabService import com.ritense.tab.web.rest.dto.TabDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.constraints.Size import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -36,6 +37,10 @@ class IkoTabResource( private val ikoTabService: IkoTabService ) { + @EndpointDescription( + en = "List IKO tabs", + nl = "IKO-tabbladen ophalen", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab") fun getIkoTabs( @PathVariable @Size(max = 256) ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewManagementResource.kt index 22849d83c6..46f0a88b35 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewManagementResource.kt @@ -28,6 +28,7 @@ import com.ritense.importer.ImportService import com.ritense.importer.exception.ImportServiceException import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.iko.PropertyField import jakarta.validation.Valid import java.time.LocalDateTime @@ -59,6 +60,10 @@ class IkoViewManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO view property fields by type", + nl = "Eigenschapsvelden van IKO-weergave ophalen per type", + ) @GetMapping("/v1/iko-property-fields/{type}/view") fun getIkoViewPropertyFields( @PathVariable type: String, @@ -67,6 +72,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO views for management", + nl = "IKO-weergaven ophalen voor beheer", + ) @GetMapping("/v1/iko-view") fun getIkoViewsForManagement( @RequestParam key: String?, @@ -84,6 +93,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO view by key", + nl = "IKO-weergave ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{key}") fun getIkoView( @PathVariable key: String, @@ -93,6 +106,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO view", + nl = "IKO-weergave aanmaken", + ) @PostMapping("/v1/iko-view/{key}") fun createIkoView( @PathVariable key: String, @@ -108,6 +125,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO view", + nl = "IKO-weergave bijwerken", + ) @PutMapping("/v1/iko-view/{key}") fun updateIkoView( @PathVariable key: String, @@ -123,6 +144,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO view", + nl = "IKO-weergave verwijderen", + ) @DeleteMapping("/v1/iko-view/{key}") fun deleteIkoView( @PathVariable key: String, @@ -132,6 +157,10 @@ class IkoViewManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Export IKO view", + nl = "IKO-weergave exporteren", + ) @GetMapping( "/v1/iko-view/{key}/export", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE] @@ -148,6 +177,10 @@ class IkoViewManagementResource( .body(baos.toByteArray()) } + @EndpointDescription( + en = "Import IKO view", + nl = "IKO-weergave importeren", + ) @PostMapping("/v1/iko-view/import") @RunWithoutAuthorization fun import( diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewResource.kt index 8b205eb4a7..3d923ba51c 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoViewResource.kt @@ -20,6 +20,7 @@ import com.ritense.iko.service.IkoViewService import com.ritense.iko.web.rest.response.IkoViewUserListResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.constraints.Size import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -40,6 +41,10 @@ class IkoViewResource( private val ikoViewService: IkoViewService, ) { + @EndpointDescription( + en = "List IKO views", + nl = "IKO-weergaven ophalen", + ) @GetMapping("/v1/iko-view") fun getIkoViews( @RequestParam @Size(max = 256) key: String?, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetManagementResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetManagementResource.kt index e7d7861de7..82d039d4e2 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetManagementResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetManagementResource.kt @@ -20,6 +20,7 @@ import com.ritense.authorization.annotation.RunWithoutAuthorization import com.ritense.iko.service.IkoWidgetService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.widget.web.rest.dto.WidgetDto import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -41,6 +42,10 @@ class IkoWidgetManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "List IKO widgets for management", + nl = "IKO-widgets ophalen voor beheer", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget") fun getIkoWidgetsForManagement( @PathVariable ikoViewKey: String, @@ -54,6 +59,10 @@ class IkoWidgetManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get IKO widget by key", + nl = "IKO-widget ophalen op sleutel", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget/{key}") fun getIkoWidget( @PathVariable ikoViewKey: String, @@ -65,6 +74,10 @@ class IkoWidgetManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create IKO widget", + nl = "IKO-widget aanmaken", + ) @PostMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget/{key}") fun createIkoWidget( @PathVariable ikoViewKey: String, @@ -82,6 +95,10 @@ class IkoWidgetManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO widget", + nl = "IKO-widget bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget/{key}") fun updateIkoWidget( @PathVariable ikoViewKey: String, @@ -101,6 +118,10 @@ class IkoWidgetManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update IKO widgets order", + nl = "Volgorde van IKO-widgets bijwerken", + ) @PutMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget") fun updateIkoWidget( @PathVariable ikoViewKey: String, @@ -131,6 +152,10 @@ class IkoWidgetManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete IKO widget", + nl = "IKO-widget verwijderen", + ) @DeleteMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget/{key}") fun deleteIkoWidget( @PathVariable ikoViewKey: String, diff --git a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetResource.kt b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetResource.kt index 77f31e78c3..7a3fa6ba08 100644 --- a/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetResource.kt +++ b/backend/iko/src/main/kotlin/com/ritense/iko/web/rest/IkoWidgetResource.kt @@ -19,6 +19,7 @@ package com.ritense.iko.web.rest import com.ritense.iko.service.IkoWidgetService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valueresolver.ValueResolverPropertyKey.Companion.IKO_VIEW_KEY import com.ritense.valueresolver.ValueResolverPropertyKey.Companion.NO_PAGE_SIZE import com.ritense.valueresolver.ValueResolverPropertyKey.Companion.PAGEABLE @@ -46,6 +47,10 @@ class IkoWidgetResource( private val ikoWidgetService: IkoWidgetService ) { + @EndpointDescription( + en = "List IKO widgets", + nl = "IKO-widgets ophalen", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget") fun getIkoWidgets( @PathVariable @Size(max = 256) ikoViewKey: String, @@ -55,6 +60,10 @@ class IkoWidgetResource( ikoWidgetService.findAllByTabKeyFilteredByDisplayConditions(ikoViewKey, tabKey).map { it.toDto() }) } + @EndpointDescription( + en = "Get IKO widget data", + nl = "IKO-widgetgegevens ophalen", + ) @GetMapping("/v1/iko-view/{ikoViewKey}/tab/{tabKey}/widget/{widgetKey}/data") fun getIkoWidgetData( @PathVariable @Size(max = 256) ikoViewKey: String, diff --git a/backend/keycloak-iam/src/main/java/com/valtimo/keycloak/service/KeycloakUserManagementService.java b/backend/keycloak-iam/src/main/java/com/valtimo/keycloak/service/KeycloakUserManagementService.java index e71f2cbdf8..0bce41c69d 100644 --- a/backend/keycloak-iam/src/main/java/com/valtimo/keycloak/service/KeycloakUserManagementService.java +++ b/backend/keycloak-iam/src/main/java/com/valtimo/keycloak/service/KeycloakUserManagementService.java @@ -22,6 +22,7 @@ import com.ritense.valtimo.contract.authentication.AuthoritiesConstants; import com.ritense.valtimo.contract.authentication.ManageableUser; import com.ritense.valtimo.contract.authentication.NamedUser; +import com.ritense.valtimo.contract.authentication.SystemPrincipal; import com.ritense.valtimo.contract.authentication.TeamManagementService; import com.ritense.valtimo.contract.authentication.User; import com.ritense.valtimo.contract.authentication.UserManagementService; @@ -67,7 +68,7 @@ public class KeycloakUserManagementService implements UserManagementService { private static final Logger logger = LoggerFactory.getLogger(KeycloakUserManagementService.class); protected static final int MAX_USERS = 100000; private static final String MAX_USERS_WARNING_MESSAGE = "Maximum number of users retrieved from keycloak: " + MAX_USERS + "."; - private static final ValtimoUser SYSTEM_VALTIMO_USER = new ValtimoUserBuilder().id(SYSTEM_ACCOUNT).lastName(SYSTEM_ACCOUNT).build(); + private static final ValtimoUser SYSTEM_VALTIMO_USER = new ValtimoUserBuilder().id(SYSTEM_ACCOUNT).username(SYSTEM_ACCOUNT).lastName(SYSTEM_ACCOUNT).build(); private final KeycloakService keycloakService; private final String clientName; @@ -250,20 +251,25 @@ public List findNamedUserByRolesWithoutAuthorization(Set role @Override public ManageableUser getCurrentUser() { - if (SecurityUtils.getCurrentUserAuthentication() == null) { + Authentication authentication = SecurityUtils.getCurrentUserAuthentication(); + if (authentication == null) { return SYSTEM_VALTIMO_USER; - } else if (SecurityUtils.getCurrentUserAuthentication() instanceof AnonymousAuthenticationToken) { + } else if (authentication instanceof AnonymousAuthenticationToken) { return null; + } else if (authentication.getPrincipal() instanceof SystemPrincipal) { + // Authenticated non-human actor (e.g. an external plugin service token) — no user account. + return SYSTEM_VALTIMO_USER; } else { return runWithoutAuthorization(() -> findByEmail(SecurityUtils.getCurrentUserLogin()).orElseThrow(() -> - new IllegalStateException("No user found for email: ${currentUserService.currentUser.email}") + new IllegalStateException("No user found for email: " + SecurityUtils.getCurrentUserLogin()) )); } } @Override public String getCurrentUserId() { - if (SecurityUtils.getCurrentUserAuthentication() != null) { + Authentication authentication = SecurityUtils.getCurrentUserAuthentication(); + if (authentication != null && !(authentication.getPrincipal() instanceof SystemPrincipal)) { return runWithoutAuthorization(() -> findUserRepresentationByEmail(SecurityUtils.getCurrentUserLogin()).orElseThrow(() -> new IllegalStateException("No user found for email: " + SecurityUtils.getCurrentUserLogin()) ).getId()); @@ -277,6 +283,10 @@ public List getCurrentUserTeams() { ManageableUser user = getCurrentUser(); if (user == null || user.getUsername() == null || teamManagementService == null) { return List.of(); + } else if (SYSTEM_ACCOUNT.equals(user.getId())) { + // The system account is not a Keycloak user and is never a team member; don't query + // teams for its (synthetic) username in every system context. + return List.of(); } else { return teamManagementService.findTeamKeysByUsername(user.getUsername()); } diff --git a/backend/keycloak-iam/src/main/kotlin/com/valtimo/keycloak/web/rest/ExternalRoleResource.kt b/backend/keycloak-iam/src/main/kotlin/com/valtimo/keycloak/web/rest/ExternalRoleResource.kt index 6eb5ad99c0..cb7728b46f 100644 --- a/backend/keycloak-iam/src/main/kotlin/com/valtimo/keycloak/web/rest/ExternalRoleResource.kt +++ b/backend/keycloak-iam/src/main/kotlin/com/valtimo/keycloak/web/rest/ExternalRoleResource.kt @@ -17,6 +17,7 @@ package com.valtimo.keycloak.web.rest import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.valtimo.keycloak.service.ExternalRoleService import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.ws.rs.NotFoundException @@ -33,6 +34,10 @@ class ExternalRoleResource( private val externalRoleService: ExternalRoleService ) { + @EndpointDescription( + en = "List external roles", + nl = "Externe rollen ophalen", + ) @GetMapping fun getExternalRoles( @RequestParam externalRoleNamePrefix: String? diff --git a/backend/keycloak-iam/src/test/java/com/valtimo/keycloak/service/KeycloakUserManagementServiceTest.java b/backend/keycloak-iam/src/test/java/com/valtimo/keycloak/service/KeycloakUserManagementServiceTest.java index ecd1c18855..31299b73d6 100644 --- a/backend/keycloak-iam/src/test/java/com/valtimo/keycloak/service/KeycloakUserManagementServiceTest.java +++ b/backend/keycloak-iam/src/test/java/com/valtimo/keycloak/service/KeycloakUserManagementServiceTest.java @@ -16,6 +16,7 @@ package com.valtimo.keycloak.service; +import static com.ritense.valtimo.contract.Constants.SYSTEM_ACCOUNT; import static com.ritense.valtimo.contract.authentication.AuthoritiesConstants.ADMIN; import static com.ritense.valtimo.contract.authentication.AuthoritiesConstants.DEVELOPER; import static com.ritense.valtimo.contract.authentication.AuthoritiesConstants.USER; @@ -30,12 +31,15 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.ritense.authorization.AuthorizationService; import com.ritense.authorization.request.EntityAuthorizationRequest; import com.ritense.valtimo.contract.OauthConfigHolder; import com.ritense.valtimo.contract.authentication.ManageableUser; +import com.ritense.valtimo.contract.authentication.SystemPrincipal; +import com.ritense.valtimo.contract.authentication.TeamManagementService; import com.ritense.valtimo.contract.authentication.User; import com.ritense.valtimo.contract.authentication.model.SearchByUserGroupsCriteria; import com.ritense.valtimo.contract.config.ValtimoProperties.Oauth; @@ -65,6 +69,7 @@ class KeycloakUserManagementServiceTest { private CacheManager cacheManager; private CacheManagerUserCache cacheManagerUserCache; private AuthorizationService authorizationService; + private TeamManagementService teamManagementService; private UserRepresentation jamesVance; private UserRepresentation johnDoe; @@ -81,12 +86,13 @@ public void before() { cacheManager = new ConcurrentMapCacheManager(); cacheManagerUserCache = new CacheManagerUserCache(cacheManager); authorizationService = mock(AuthorizationService.class); + teamManagementService = mock(TeamManagementService.class); userManagementService = new KeycloakUserManagementService( keycloakService, "clientName", cacheManagerUserCache, authorizationService, - mock() + teamManagementService ); jamesVance = newUser("James", "Vance", List.of(USER)); @@ -296,6 +302,66 @@ void shouldThrowExceptionWhenUserViewPermissionIsDenied() { .hasMessage("Permission denied"); } + @Test + void getCurrentUserShouldResolveSystemUserForSystemPrincipalAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + when(authentication.getPrincipal()).thenReturn(new SystemPrincipal() {}); + + var user = userManagementService.getCurrentUser(); + + assertThat(user).isNotNull(); + assertThat(user.getId()).isEqualTo(SYSTEM_ACCOUNT); + assertThat(user.getUsername()).isEqualTo(SYSTEM_ACCOUNT); + } + + @Test + void getCurrentUserShouldResolveSystemUserWhenNoAuthenticationIsPresent() { + SecurityContextHolder.clearContext(); + + var user = userManagementService.getCurrentUser(); + + assertThat(user).isNotNull(); + assertThat(user.getId()).isEqualTo(SYSTEM_ACCOUNT); + } + + @Test + void getCurrentUserIdShouldResolveSystemAccountForSystemPrincipalAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + when(authentication.getPrincipal()).thenReturn(new SystemPrincipal() {}); + + assertThat(userManagementService.getCurrentUserId()).isEqualTo(SYSTEM_ACCOUNT); + } + + @Test + void getCurrentUserTeamsShouldReturnNoTeamsForSystemPrincipalAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + when(authentication.getPrincipal()).thenReturn(new SystemPrincipal() {}); + + assertThat(userManagementService.getCurrentUserTeams()).isEmpty(); + verifyNoInteractions(teamManagementService); + } + + @Test + void getCurrentUserTeamsShouldReturnNoTeamsWhenNoAuthenticationIsPresent() { + SecurityContextHolder.clearContext(); + + assertThat(userManagementService.getCurrentUserTeams()).isEmpty(); + verifyNoInteractions(teamManagementService); + } + + @Test + void getCurrentUserTeamsShouldQueryTeamsForRegularUserAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + when(authentication.getName()).thenReturn(johnDoe.getEmail()); + when(keycloakService.usersResource(any()).searchByEmail(eq(johnDoe.getEmail()), eq(true))) + .thenReturn(List.of(johnDoe)); + when(teamManagementService.findTeamKeysByUsername(johnDoe.getUsername())) + .thenReturn(List.of("team-a")); + + assertThat(userManagementService.getCurrentUserTeams()).containsExactly("team-a"); + verify(teamManagementService).findTeamKeysByUsername(johnDoe.getUsername()); + } + private UserRepresentation newUser(String firstName, String lastName, List roles, String username) { var user = new UserRepresentation(); user.setId(Integer.toString(Objects.hash(firstName, lastName, roles))); diff --git a/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/AdminLocalizationResource.kt b/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/AdminLocalizationResource.kt index 1536a49c3f..198bb9493c 100644 --- a/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/AdminLocalizationResource.kt +++ b/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/AdminLocalizationResource.kt @@ -22,6 +22,7 @@ import com.ritense.localization.web.rest.dto.LocalizationResponseDto import com.ritense.localization.web.rest.dto.LocalizationUpdateRequestDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -36,6 +37,10 @@ import org.springframework.web.bind.annotation.RequestMapping class AdminLocalizationResource( private val localizationService: LocalizationService, ) { + @EndpointDescription( + en = "Update localization by language key", + nl = "Lokalisatie bijwerken op taalsleutel", + ) @PutMapping("/v1/localization/{languageKey}") fun editLocalization( @PathVariable(name = "languageKey") languageKey: String, @@ -45,6 +50,10 @@ class AdminLocalizationResource( return ResponseEntity.ok(updatedLocalization) } + @EndpointDescription( + en = "Update localizations", + nl = "Lokalisaties bijwerken", + ) @PutMapping("/v1/localization") fun editLocalizations( @Valid @RequestBody localizations: List diff --git a/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/LocalizationResource.kt b/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/LocalizationResource.kt index c458d812eb..e76369da19 100644 --- a/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/LocalizationResource.kt +++ b/backend/localization/src/main/kotlin/com/ritense/localization/web/rest/LocalizationResource.kt @@ -21,6 +21,7 @@ import com.ritense.localization.service.LocalizationService import com.ritense.localization.web.rest.dto.LocalizationResponseDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping @@ -34,6 +35,10 @@ class LocalizationResource( private val localizationService: LocalizationService ) { + @EndpointDescription( + en = "List localizations", + nl = "Lokalisaties ophalen", + ) @GetMapping("/v1/localization") fun getLocalizations(): ResponseEntity> { val localizationResponseDtos = localizationService.getLocalizations() @@ -41,6 +46,10 @@ class LocalizationResource( return ResponseEntity.ok(localizationResponseDtos) } + @EndpointDescription( + en = "Get localization by language key", + nl = "Lokalisatie ophalen op taalsleutel", + ) @GetMapping("/v1/localization/{languageKey}") fun getLocalization(@PathVariable languageKey: String): ResponseEntity { val data = localizationService.getLocalization(languageKey) diff --git a/backend/logging/src/main/kotlin/com/ritense/logging/web/rest/LoggingEventManagementResource.kt b/backend/logging/src/main/kotlin/com/ritense/logging/web/rest/LoggingEventManagementResource.kt index 37ff490076..74bc46578c 100644 --- a/backend/logging/src/main/kotlin/com/ritense/logging/web/rest/LoggingEventManagementResource.kt +++ b/backend/logging/src/main/kotlin/com/ritense/logging/web/rest/LoggingEventManagementResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import com.ritense.logging.web.rest.dto.LoggingEventResponse import com.ritense.logging.web.rest.dto.LoggingEventSearchRequest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl @@ -42,6 +43,10 @@ class LoggingEventManagementResource( ) { @Transactional(readOnly = true) + @EndpointDescription( + en = "Search logging events", + nl = "Logging-events zoeken", + ) @PostMapping("/v1/logging") fun searchLoggingEvents( @Valid @RequestBody searchRequest: LoggingEventSearchRequest, diff --git a/backend/mail/mandrill/src/main/java/com/ritense/mail/web/rest/WebhookResource.java b/backend/mail/mandrill/src/main/java/com/ritense/mail/web/rest/WebhookResource.java index 758ead814e..9834c9bf92 100644 --- a/backend/mail/mandrill/src/main/java/com/ritense/mail/web/rest/WebhookResource.java +++ b/backend/mail/mandrill/src/main/java/com/ritense/mail/web/rest/WebhookResource.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,8 @@ import com.ritense.mail.domain.webhook.MandrillWebhookRequest; import com.ritense.mail.service.WebhookService; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; +import jakarta.validation.Valid; import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,12 +49,20 @@ public WebhookResource(WebhookService webhookService) { this.webhookService = webhookService; } + @EndpointDescription( + en = "Check the Mandrill webhook exists", + nl = "Controleren of de Mandrill-webhook bestaat" + ) @GetMapping("/v1/mandrill/webhook") public ResponseEntity exists() { // Exists for Mandrill's check whether or not the endpoint exists. return ResponseEntity.ok().build(); } + @EndpointDescription( + en = "Handle a Mandrill webhook event", + nl = "Mandrill-webhook-event verwerken" + ) @PostMapping(value = "/v1/mandrill/webhook", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity mandrillWebhook( @RequestHeader("X-Mandrill-Signature") String authenticationKey, diff --git a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneInstanceResource.java b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneInstanceResource.java index a0fad5366f..154811ac0d 100644 --- a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneInstanceResource.java +++ b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneInstanceResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.milestones.service.MilestoneInstanceService; import com.ritense.valtimo.milestones.web.rest.dto.FlowNodeDTO; import com.ritense.valtimo.milestones.web.rest.dto.MilestoneInstanceDTO; @@ -51,12 +52,20 @@ public MilestoneInstanceResource(RepositoryService repositoryService, MilestoneI this.milestoneInstanceService = milestoneInstanceService; } + @EndpointDescription( + en = "List all milestone instances", + nl = "Alle mijlpaal-instanties ophalen" + ) @GetMapping("/v1/milestone-instances") public ResponseEntity> getMilestoneInstances() { logger.debug("REST request to get all milestone instances"); return ResponseEntity.ok(milestoneInstanceService.getAllMilestoneInstances()); } + @EndpointDescription( + en = "Get diagram flow nodes for a process definition", + nl = "Diagram-flownodes voor een procesdefinitie ophalen" + ) @GetMapping("/v1/milestones/{processDefinitionId}/flownodes") @ResponseBody public ResponseEntity getDiagramFlowNodes(@PathVariable String processDefinitionId) { diff --git a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneResource.java b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneResource.java index 5349370750..75343406d7 100644 --- a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneResource.java +++ b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.milestones.domain.Milestone; import com.ritense.valtimo.milestones.repository.MilestoneRepository; import com.ritense.valtimo.milestones.service.MilestoneService; @@ -59,6 +60,10 @@ public MilestoneResource(MilestoneService milestoneService, MilestoneRepository this.milestoneMapper = milestoneMapper; } + @EndpointDescription( + en = "Get a milestone by id", + nl = "Mijlpaal op id ophalen" + ) @GetMapping("/v1/milestones/{id}") public ResponseEntity getMilestone(@PathVariable Long id) { logger.debug("REST request to get Milestone : {}", id); @@ -69,6 +74,10 @@ public ResponseEntity getMilestone(@PathVariable Long id) { .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); } + @EndpointDescription( + en = "List all milestones", + nl = "Alle mijlpalen ophalen" + ) @GetMapping("/v1/milestones") public ResponseEntity> listMilestones() { logger.debug("REST request to get all milestones"); @@ -76,6 +85,10 @@ public ResponseEntity> listMilestones() { return ResponseEntity.ok(milestoneDTOList); } + @EndpointDescription( + en = "Save a milestone", + nl = "Mijlpaal opslaan" + ) @PostMapping("/v1/milestones") public ResponseEntity saveMilestone(@Valid @RequestBody MilestoneSaveDTO milestoneSaveDTO) throws Exception { logger.debug("REST request to save Milestone : {}", milestoneSaveDTO); @@ -90,6 +103,10 @@ public ResponseEntity saveMilestone(@Valid @RequestBody MilestoneS .body(savedMilestoneDTO); } + @EndpointDescription( + en = "Delete a milestone", + nl = "Mijlpaal verwijderen" + ) @DeleteMapping("/v1/milestones/{id}") public ResponseEntity deleteMilestone(@PathVariable Long id) throws IllegalStateException { logger.debug("REST request to delete Milestone : {}", id); diff --git a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneSetResource.java b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneSetResource.java index 7b360b4d2a..32f941ef8a 100644 --- a/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneSetResource.java +++ b/backend/milestones/src/main/java/com/ritense/valtimo/milestones/web/rest/MilestoneSetResource.java @@ -19,6 +19,7 @@ import static com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE; import com.ritense.valtimo.contract.annotation.SkipComponentScan; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.milestones.domain.MilestoneSet; import com.ritense.valtimo.milestones.repository.MilestoneSetRepository; import com.ritense.valtimo.milestones.service.MilestoneSetService; @@ -55,6 +56,10 @@ public MilestoneSetResource(MilestoneSetService milestoneSetService, MilestoneSe this.milestoneSetRepository = milestoneSetRepository; } + @EndpointDescription( + en = "Get a milestone set by id", + nl = "Mijlpalenset op id ophalen" + ) @GetMapping("/v1/milestone-sets/{id}") public ResponseEntity getMilestoneSet(@PathVariable Long id) { logger.debug("REST request to get Milestone set : {}", id); @@ -64,6 +69,10 @@ public ResponseEntity getMilestoneSet(@PathVariable Long id) { .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); } + @EndpointDescription( + en = "List all milestone sets", + nl = "Alle mijlpalensets ophalen" + ) @GetMapping("/v1/milestone-sets") public ResponseEntity> listMilestoneSets() { logger.debug("REST request to get all milestone sets"); @@ -71,6 +80,10 @@ public ResponseEntity> listMilestoneSets() { return ResponseEntity.ok(milestoneSetList); } + @EndpointDescription( + en = "Save a milestone set", + nl = "Mijlpalenset opslaan" + ) @PostMapping("/v1/milestone-sets") public ResponseEntity saveMilestoneSet(@Valid @RequestBody MilestoneSetSaveDTO dto) { logger.debug("REST request to save Milestone set : {}", dto); @@ -91,6 +104,10 @@ public ResponseEntity saveMilestoneSet(@Valid @RequestBody Milesto } + @EndpointDescription( + en = "Delete a milestone set", + nl = "Mijlpalenset verwijderen" + ) @DeleteMapping("/v1/milestone-sets/{id}") public ResponseEntity deleteMilestoneSet(@PathVariable Long id) { logger.debug("REST request to delete Milestone set : {}", id); diff --git a/backend/notes/src/main/kotlin/com/ritense/note/web/rest/NoteResource.kt b/backend/notes/src/main/kotlin/com/ritense/note/web/rest/NoteResource.kt index 07574f7d20..27f292d1f9 100644 --- a/backend/notes/src/main/kotlin/com/ritense/note/web/rest/NoteResource.kt +++ b/backend/notes/src/main/kotlin/com/ritense/note/web/rest/NoteResource.kt @@ -24,6 +24,7 @@ import com.ritense.note.web.rest.dto.NoteResponseDto import com.ritense.note.web.rest.dto.NoteUpdateRequestDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -48,6 +49,10 @@ class NoteResource( private val noteService: NoteService, private val documentService: DocumentService, ) { + @EndpointDescription( + en = "List notes for a document", + nl = "Notities van een document ophalen", + ) @GetMapping("/v1/document/{documentId}/note") fun getNotes( @PathVariable(name = "documentId") documentId: UUID, @@ -63,6 +68,10 @@ class NoteResource( return ResponseEntity.ok(notes.map { note -> NoteResponseDto(note) }) } + @EndpointDescription( + en = "Create a note for a document", + nl = "Notitie voor een document aanmaken", + ) @PostMapping("/v1/document/{documentId}/note") fun createNote( @PathVariable(name = "documentId") documentId: UUID, @@ -77,6 +86,10 @@ class NoteResource( return ResponseEntity.ok(NoteResponseDto(note)) } + @EndpointDescription( + en = "Update a note", + nl = "Notitie bijwerken", + ) @PutMapping("/v1/note/{noteId}") fun editNote( @PathVariable(name = "noteId") noteId: UUID, @@ -86,6 +99,10 @@ class NoteResource( return ResponseEntity.ok(NoteResponseDto(note)) } + @EndpointDescription( + en = "Delete a note", + nl = "Notitie verwijderen", + ) @DeleteMapping("/v1/note/{noteId}") fun deleteNote( @PathVariable(name = "noteId") noteId: UUID diff --git a/backend/plugin-valtimo/build.gradle b/backend/plugin-valtimo/build.gradle index 6cafb516b8..e2d77bc943 100644 --- a/backend/plugin-valtimo/build.gradle +++ b/backend/plugin-valtimo/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation project(":backend:exporter") implementation project(":backend:logging") implementation project(":backend:process-document") + implementation project(":backend:value-resolver") implementation "org.springframework.boot:spring-boot-starter" implementation "org.springframework.boot:spring-boot-starter-data-jpa" diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt index 9c518c11a6..c7d84f588a 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt @@ -218,13 +218,13 @@ class ProcessLinkAutoConfiguration { @Bean @ConditionalOnMissingBean(ProcessLinkChangedEventListener::class) fun processLinkChangedEventListener( - pluginConfigurationMappingResolver: PluginConfigurationMappingResolver, + pluginConfigurationMappingResolvers: List, ): ProcessLinkChangedEventListener { - return ProcessLinkChangedEventListener(pluginConfigurationMappingResolver) + return ProcessLinkChangedEventListener(pluginConfigurationMappingResolvers) } @Bean - @ConditionalOnMissingBean(PluginConfigurationMappingResolver::class) + @ConditionalOnMissingBean(PluginConfigurationMappingResolverImpl::class) fun pluginConfigurationMappingResolver( pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository, pluginConfigurationRepository: PluginConfigurationRepository, diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt index b89eaa3538..7bb5a62bad 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt @@ -25,7 +25,7 @@ import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener class ProcessLinkChangedEventListener( - private val pluginConfigurationMappingResolver: PluginConfigurationMappingResolver + private val pluginConfigurationMappingResolvers: List ) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -44,10 +44,12 @@ class ProcessLinkChangedEventListener( } private fun recheckIssues(processDefinitionId: String) { - try { - pluginConfigurationMappingResolver.recheckIssuesForProcessDefinition(processDefinitionId) - } catch (e: Exception) { - logger.debug(e) { "Could not recheck plugin configuration issues for process definition $processDefinitionId" } + pluginConfigurationMappingResolvers.forEach { resolver -> + try { + resolver.recheckIssuesForProcessDefinition(processDefinitionId) + } catch (e: Exception) { + logger.debug(e) { "Could not recheck plugin configuration issues for process definition $processDefinitionId" } + } } } diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt index 497b820c41..dd168c6cb0 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt @@ -19,6 +19,7 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.autodeployment.ProcessLinkDeployDto @@ -35,6 +36,7 @@ class PluginProcessLinkDeployDto( val actionProperties: ObjectNode? = JsonNodeFactory.instance.objectNode(), val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkDeployDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt index 3af2f3206a..819fff7d8c 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt @@ -19,6 +19,7 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -34,6 +35,7 @@ class PluginProcessLinkExportResponseDto( val actionProperties: ObjectNode? = JsonNodeFactory.instance.objectNode(), val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkExportResponseDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt index 8023f33bd9..5df550aa0d 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt @@ -17,17 +17,20 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.exporter.manifest.ArtifactDependency import com.ritense.exporter.manifest.DependencyType import com.ritense.exporter.manifest.ResolvableValue import com.ritense.logging.LoggableResource import com.ritense.logging.withLoggingContext +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationId import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.domain.PluginProcessLink import com.ritense.plugin.repository.PluginConfigurationRepository import com.ritense.plugin.repository.PluginDefinitionRepository +import com.ritense.plugin.service.PluginActionResultMappingValidator import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.plugin.web.rest.request.PluginProcessLinkCreateDto import com.ritense.plugin.web.rest.request.PluginProcessLinkUpdateDto @@ -35,6 +38,7 @@ import com.ritense.plugin.web.rest.result.PluginProcessLinkResultDto import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto @@ -81,6 +85,7 @@ class PluginProcessLinkMapper( pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, pluginActionDefinitionKey = processLink.pluginActionDefinitionKey, actionProperties = processLink.actionProperties, + actionResultMappings = processLink.actionResultMappings, ) } } @@ -96,6 +101,7 @@ class PluginProcessLinkMapper( activityType = deployDto.activityType, referenceType = deployDto.referenceType, pluginDefinitionKey = deployDto.pluginDefinitionKey, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -112,6 +118,7 @@ class PluginProcessLinkMapper( actionProperties = deployDto.actionProperties, referenceType = deployDto.referenceType, pluginDefinitionKey = deployDto.pluginDefinitionKey, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -132,6 +139,7 @@ class PluginProcessLinkMapper( actionProperties = processLink.actionProperties, referenceType = processLink.pluginConfigurationReference.type, pluginDefinitionKey = definitionKey, + actionResultMappings = processLink.actionResultMappings, ) } } @@ -166,6 +174,7 @@ class PluginProcessLinkMapper( val reference = createReference(createRequestDto.referenceType, createRequestDto.pluginDefinitionKey) val configurationId = createRequestDto.pluginConfigurationId?.let { PluginConfigurationId.existingId(it) } validateReference(reference.type, configurationId) + PluginActionResultMappingValidator.validate(createRequestDto.actionResultMappings) return PluginProcessLink( id = UUID.randomUUID(), processDefinitionId = createRequestDto.processDefinitionId, @@ -175,6 +184,7 @@ class PluginProcessLinkMapper( pluginConfigurationReference = reference, pluginActionDefinitionKey = createRequestDto.pluginActionDefinitionKey, actionProperties = createRequestDto.actionProperties, + actionResultMappings = createRequestDto.actionResultMappings, ) } @@ -188,6 +198,7 @@ class PluginProcessLinkMapper( val reference = createReference(updateRequestDto.referenceType, updateRequestDto.pluginDefinitionKey) val configurationId = updateRequestDto.pluginConfigurationId?.let { PluginConfigurationId.existingId(it) } validateReference(reference.type, configurationId) + PluginActionResultMappingValidator.validate(updateRequestDto.actionResultMappings) PluginProcessLink( id = updateRequestDto.id, processDefinitionId = processLinkToUpdate.processDefinitionId, @@ -197,6 +208,7 @@ class PluginProcessLinkMapper( pluginConfigurationReference = reference, pluginActionDefinitionKey = updateRequestDto.pluginActionDefinitionKey, actionProperties = updateRequestDto.actionProperties, + actionResultMappings = updateRequestDto.actionResultMappings, ) } } @@ -219,6 +231,10 @@ class PluginProcessLinkMapper( } } + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "pluginConfigurationId", mappings) + } + override fun afterImport( caseDefinitionId: CaseDefinitionId, processDefinitionIds: Set, diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt index 9459d3e6ef..a7850df5c0 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt @@ -33,4 +33,35 @@ interface ValtimoPluginProcessLinkRepository : BaseProcessLinkRepository ): List + + /** + * `ExternalPluginProcessLink` (the `external_plugin` `process_link_type` row) lives in + * `:backend:external-plugin`, which `:backend:building-block` does not depend on. Rather than + * introduce that module dependency, this reads the shared `process_link` columns + * (`plugin_definition_key`/`plugin_definition_version`, populated by both the embedded and + * external plugin systems via the shared `PluginConfigurationReference` embeddable) natively, + * filtered to external `BUILDING_BLOCK` references only. + */ + @Query( + value = """ + SELECT DISTINCT + plugin_definition_key AS pluginDefinitionKey, + plugin_definition_version AS pluginDefinitionVersion + FROM process_link + WHERE process_link_type = 'external_plugin' + AND reference_type = 'BUILDING_BLOCK' + AND process_definition_id IN :processDefinitionIds + AND plugin_definition_key IS NOT NULL + AND plugin_definition_version IS NOT NULL + """, + nativeQuery = true + ) + fun findExternalPluginReferencesByProcessDefinitionIds( + @Param("processDefinitionIds") processDefinitionIds: Collection + ): List +} + +interface ExternalPluginReferenceProjection { + fun getPluginDefinitionKey(): String + fun getPluginDefinitionVersion(): String } diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/web/rest/PluginProcessLinkResource.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/web/rest/PluginProcessLinkResource.kt index 3312742bf0..999f96e5cb 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/web/rest/PluginProcessLinkResource.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/web/rest/PluginProcessLinkResource.kt @@ -18,6 +18,7 @@ package com.ritense.valtimo.processlink.web.rest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.processlink.service.PluginProcessLinkService import com.ritense.valtimo.processlink.web.rest.result.CompatiblePluginProcessLinks import org.springframework.http.ResponseEntity @@ -32,6 +33,10 @@ import org.springframework.web.bind.annotation.RestController class PluginProcessLinkResource( private var pluginProcessLinkService: PluginProcessLinkService ) { + @EndpointDescription( + en = "List compatible plugin process links", + nl = "Compatibele plugin-proceskoppelingen ophalen", + ) @GetMapping("/v1/process-link/plugin") fun getCompatiblePluginProcessLinks( @RequestParam("pluginActionDefinitionKey") pluginActionDefinitionKey: String diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt new file mode 100644 index 0000000000..98e19fbf74 --- /dev/null +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimo.processlink + +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import com.ritense.valtimo.processlink.listener.ProcessLinkChangedEventListener +import com.ritense.valtimo.processlink.service.PluginConfigurationMappingResolverImpl +import java.lang.reflect.ParameterizedType +import java.lang.reflect.WildcardType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean + +/** + * Multiple modules register a [PluginConfigurationMappingResolver] bean (this module's embedded + * resolver and external-plugin's), and every real application has both modules on the classpath. + * That cross-module wiring is not exercised by any single-module context test, so these contracts + * are pinned by reflection: + * + * - the resolver bean's `@ConditionalOnMissingBean` must target its own concrete class — an + * interface-typed condition makes whichever module's configuration is processed first silently + * suppress the other module's resolver; + * - every consumer that used to inject the single resolver must inject the full list — a + * single-valued injection point fails the application boot with a + * `NoUniqueBeanDefinitionException` once the second resolver exists. + */ +class ProcessLinkAutoConfigurationWiringTest { + + @Test + fun `resolver bean condition targets its own concrete class, not the shared interface`() { + val beanMethod = ProcessLinkAutoConfiguration::class.java.declaredMethods + .single { it.name == "pluginConfigurationMappingResolver" } + + val condition = beanMethod.getAnnotation(ConditionalOnMissingBean::class.java) + + assertThat(condition).isNotNull + assertThat(condition.value.map { it.java }).containsExactly(PluginConfigurationMappingResolverImpl::class.java) + } + + @Test + fun `process link changed event listener injects all mapping resolvers`() { + val constructor = ProcessLinkChangedEventListener::class.java.constructors.single() + val parameterType = constructor.genericParameterTypes.single() + + assertThat(parameterType).isInstanceOf(ParameterizedType::class.java) + parameterType as ParameterizedType + assertThat(parameterType.rawType).isEqualTo(List::class.java) + // Kotlin's List surfaces as List in Java reflection. + val elementType = when (val argument = parameterType.actualTypeArguments.single()) { + is WildcardType -> argument.upperBounds.single() + else -> argument + } + assertThat(elementType).isEqualTo(PluginConfigurationMappingResolver::class.java) + } +} diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt index 9862b2ce70..2848f1ee90 100644 --- a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt @@ -27,6 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -40,7 +41,7 @@ class ProcessLinkChangedEventListenerTest { @BeforeEach fun before() { - listener = ProcessLinkChangedEventListener(pluginConfigurationMappingResolver) + listener = ProcessLinkChangedEventListener(listOf(pluginConfigurationMappingResolver)) } @Test @@ -73,4 +74,20 @@ class ProcessLinkChangedEventListenerTest { assertThatCode { listener.onProcessLinkCreated(ProcessLinkCreatedEvent("plugin", "pd-1")) } .doesNotThrowAnyException() } + + @Test + fun `delegates to every registered resolver, one throwing does not block the others`() { + val secondResolver: PluginConfigurationMappingResolver = mock() + doThrow(RuntimeException("boom")) + .whenever(pluginConfigurationMappingResolver) + .recheckIssuesForProcessDefinition("pd-1") + val multiResolverListener = + ProcessLinkChangedEventListener(listOf(pluginConfigurationMappingResolver, secondResolver)) + + assertThatCode { multiResolverListener.onProcessLinkCreated(ProcessLinkCreatedEvent("plugin", "pd-1")) } + .doesNotThrowAnyException() + + verify(pluginConfigurationMappingResolver).recheckIssuesForProcessDefinition("pd-1") + verify(secondResolver).recheckIssuesForProcessDefinition("pd-1") + } } diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt index 7a2a5d2661..b59213f6fc 100644 --- a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt @@ -79,6 +79,27 @@ class PluginProcessLinkMapperTest { ) } + @Test + fun `applyPluginConfigurationMappings rewrites pluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = jacksonObjectMapper().createObjectNode().put("pluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("pluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings nulls pluginConfigurationId when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = jacksonObjectMapper().createObjectNode().put("pluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("pluginConfigurationId").isNull).isTrue() + } + @Test fun `afterImport emits detected event when FIXED link has missing pluginConfigurationId`() { val configId = PluginConfigurationId.existingId(UUID.randomUUID()) diff --git a/backend/plugin/build.gradle b/backend/plugin/build.gradle index e990434261..e2eef39ce6 100644 --- a/backend/plugin/build.gradle +++ b/backend/plugin/build.gradle @@ -38,6 +38,7 @@ dependencies { exclude(group: "com.ritense.valtimo", module: "core") } implementation project(":backend:contract") + implementation project(":backend:core") implementation project(":backend:logging") implementation project(":backend:value-resolver") diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt index 0ce283d64c..6eeef7722c 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt @@ -34,15 +34,20 @@ import com.ritense.plugin.repository.PluginPropertyRepository import com.ritense.plugin.security.config.PluginHttpSecurityConfigurer import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.service.PluginActionResultHandler import com.ritense.plugin.service.PluginConfigurationListener +import com.ritense.plugin.service.PluginConfigurationUsageResolver import com.ritense.plugin.service.PluginService +import com.ritense.plugin.service.ProcessDefinitionUsageMetaResolver import com.ritense.plugin.web.rest.PluginConfigurationResource import com.ritense.plugin.web.rest.PluginDefinitionResource import com.ritense.plugin.web.rest.converter.StringToActivityTypeConverter import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valueresolver.ValueResolverService import jakarta.persistence.EntityManager import jakarta.validation.Validator +import org.operaton.bpm.engine.RepositoryService import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean @@ -115,6 +120,26 @@ class PluginAutoConfiguration { return PluginHttpSecurityConfigurer() } + @Bean + @ConditionalOnMissingBean(ProcessDefinitionUsageMetaResolver::class) + fun processDefinitionUsageMetaResolver( + operatonRepositoryService: OperatonRepositoryService, + bpmnRepositoryService: RepositoryService, + ) = ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService) + + @Bean + @ConditionalOnMissingBean(PluginConfigurationUsageResolver::class) + @Suppress("DEPRECATION") + fun pluginConfigurationUsageResolver( + pluginConfigurationRepository: PluginConfigurationRepository, + pluginProcessLinkRepositoryImpl: PluginProcessLinkRepositoryImpl, + metaResolver: ProcessDefinitionUsageMetaResolver, + ) = PluginConfigurationUsageResolver( + pluginConfigurationRepository, + pluginProcessLinkRepositoryImpl, + metaResolver, + ) + @Bean fun pluginService( pluginDefinitionRepository: PluginDefinitionRepository, @@ -131,6 +156,8 @@ class PluginAutoConfiguration { environment: Environment, caseDefinitionChecker: CaseDefinitionChecker, buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, + pluginConfigurationUsageResolver: PluginConfigurationUsageResolver, + pluginActionResultHandler: PluginActionResultHandler, ): PluginService { return PluginService( pluginDefinitionRepository, @@ -147,9 +174,18 @@ class PluginAutoConfiguration { environment, caseDefinitionChecker, buildingBlockPluginConfigurationResolver, + pluginConfigurationUsageResolver, + pluginActionResultHandler, ) } + @Bean + @ConditionalOnMissingBean(PluginActionResultHandler::class) + fun pluginActionResultHandler( + valueResolverService: ValueResolverService, + objectMapper: ObjectMapper, + ) = PluginActionResultHandler(valueResolverService, objectMapper) + @Bean @ConditionalOnMissingBean fun pluginConfigurationSearchRepository(entityManager: EntityManager): PluginConfigurationSearchRepository { diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt new file mode 100644 index 0000000000..7ac1156c55 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.domain + +/** + * A single write-back rule for a `@PluginAction` (or external-plugin action) return value. + * [source] is an RFC 6901 JSON pointer into the action's result (an empty string selects the + * whole result); [target] is a value-resolver-prefixed key (`doc:`, `pv:`, `case:`) describing + * where to write it. Stored as a JSON list on the `process_link.action_result_mappings` column, + * shared by both [PluginProcessLink] (embedded) and the external-plugin process link. + */ +data class PluginActionResultMapping( + val source: String, + val target: String, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt index 6e03b3bfd7..024f95a84f 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt @@ -28,7 +28,20 @@ data class PluginConfigurationReference( val type: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, @Column(name = "plugin_definition_key") - val pluginDefinitionKey: String? = null + val pluginDefinitionKey: String? = null, + + /** + * Design-time metadata only, populated exclusively by the external-plugin system (embedded + * plugin definitions are unversioned, so embedded usage always keeps this `null`). Used for + * validation, UI warnings and the import chooser; the **runtime** invocation version always + * derives from the resolved configuration's definition, never from this field. + * + * Whether `null` is allowed/required per [type] and per caller (embedded vs. external) is + * enforced by the respective `ProcessLinkMapper`, not here — this embeddable is shared by both + * systems and cannot encode a rule that only applies to one of them. + */ + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, ) { init { when (type) { diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt index c76d9a02f3..40e3986f1d 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt @@ -48,7 +48,11 @@ class PluginProcessLink( val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), @Column(name = "plugin_action_definition_key", nullable = false) - val pluginActionDefinitionKey: String + val pluginActionDefinitionKey: String, + + @Type(value = JsonType::class) + @Column(name = "action_result_mappings", columnDefinition = "JSON") + val actionResultMappings: List = emptyList(), ) : ProcessLink( id, @@ -107,6 +111,7 @@ class PluginProcessLink( pluginConfigurationId: PluginConfigurationId? = this.pluginConfigurationId, pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, pluginActionDefinitionKey: String = this.pluginActionDefinitionKey, + actionResultMappings: List = this.actionResultMappings, ) = PluginProcessLink( id = id, processDefinitionId = processDefinitionId, @@ -115,7 +120,8 @@ class PluginProcessLink( actionProperties = actionProperties, pluginConfigurationId = pluginConfigurationId, pluginConfigurationReference = pluginConfigurationReference, - pluginActionDefinitionKey = pluginActionDefinitionKey + pluginActionDefinitionKey = pluginActionDefinitionKey, + actionResultMappings = actionResultMappings, ) override fun equals(other: Any?): Boolean { @@ -129,6 +135,7 @@ class PluginProcessLink( if (pluginConfigurationId != other.pluginConfigurationId) return false if (pluginConfigurationReference != other.pluginConfigurationReference) return false if (pluginActionDefinitionKey != other.pluginActionDefinitionKey) return false + if (actionResultMappings != other.actionResultMappings) return false return true } @@ -139,6 +146,7 @@ class PluginProcessLink( result = 31 * result + (pluginConfigurationId?.hashCode() ?: 0) result = 31 * result + pluginConfigurationReference.hashCode() result = 31 * result + pluginActionDefinitionKey.hashCode() + result = 31 * result + actionResultMappings.hashCode() return result } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/exception/PluginConfigurationInUseException.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/exception/PluginConfigurationInUseException.kt new file mode 100644 index 0000000000..89c435b59f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/exception/PluginConfigurationInUseException.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.exception + +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import org.zalando.problem.AbstractThrowableProblem +import org.zalando.problem.Exceptional +import org.zalando.problem.Status +import java.util.UUID + +class PluginConfigurationInUseException( + configurationId: UUID, + usages: Collection, +) : AbstractThrowableProblem( + null, + "Plugin configuration is in use", + Status.CONFLICT, + "One or more BPMN process links reference this plugin configuration. " + + "Remove the references before deleting the configuration.", + null, + null, + mapOf( + "configurationId" to configurationId.toString(), + "usages" to usages, + ), +) { + override fun getCause(): Exceptional? = null +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/security/config/PluginHttpSecurityConfigurer.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/security/config/PluginHttpSecurityConfigurer.kt index 7dbfda2ebe..8f2cbdef81 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/security/config/PluginHttpSecurityConfigurer.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/security/config/PluginHttpSecurityConfigurer.kt @@ -36,6 +36,7 @@ class PluginHttpSecurityConfigurer: HttpSecurityConfigurer { .requestMatchers(antMatcher(POST, "/api/v1/plugin/configuration")).hasAuthority(ADMIN) .requestMatchers(antMatcher(PUT, "/api/v1/plugin/configuration/{pluginConfigurationId}")).hasAuthority(ADMIN) .requestMatchers(antMatcher(DELETE, "/api/v1/plugin/configuration/{pluginConfigurationId}")).hasAuthority(ADMIN) + .requestMatchers(antMatcher(GET, "/api/v1/plugin/configuration/{pluginConfigurationId}/usages")).hasAuthority(ADMIN) .requestMatchers(antMatcher(GET, "/api/v1/plugin/definition/{pluginDefinitionKey}/action")).hasAuthority(ADMIN) .requestMatchers(antMatcher(GET, "/api/v1/plugin/configuration/export")).hasAuthority(ADMIN) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt index 3419c375c8..61b81e926d 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt @@ -23,4 +23,14 @@ import java.util.UUID interface BuildingBlockPluginConfigurationResolver { fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? fun resolve(task: DelegateTask, pluginDefinitionKey: String): UUID? + + /** + * Resolves the configuration id for the first `pluginConfigurationMappings` key that starts with + * [keyPrefix], or `null` when none matches. Lets a caller resolve version-tolerantly: the + * external-plugin system keys building-block mappings as `external-plugin:@`, + * so a prefix of `external-plugin:@` matches a mapping made for a *different* version of + * the same plugin (the resolved configuration's version then applies at runtime — D1). Callers try + * the exact key first and fall back to this. Default no-op for resolvers that don't support it. + */ + fun resolveByKeyPrefix(execution: DelegateExecution, keyPrefix: String): UUID? = null } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt new file mode 100644 index 0000000000..8d3d1c95c5 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import java.util.UUID + +/** + * SPI reporting where building-block surfaces reference a plugin configuration through their + * `pluginConfigurationMappings` — the call-activity process link and the case-definition ↔ + * building-block link. Mapping values are configuration ids of either plugin system (embedded or + * external), so the finder is system-agnostic. Implemented by the building-block module (this + * interface lives here for the same reason as [BuildingBlockPluginConfigurationResolver]: both + * plugin systems depend on `:backend:plugin`, neither may depend on `:backend:building-block`); + * plugin systems consult it from their delete guards so a configuration referenced only by a + * building block cannot be deleted out from under it. + */ +interface BuildingBlockPluginMappingUsageFinder { + + fun findUsages(configurationId: UUID): List +} + +/** + * One `pluginConfigurationMappings` entry referencing the configuration, in one of two shapes: + * - a building-block **call-activity process link**: [processLinkId], [processDefinitionId] and + * [activityId] are set, the case fields are null; + * - a **case-definition ↔ building-block link**: [caseDefinitionKey] and [caseDefinitionVersionTag] + * are set, the process-link fields are null. + */ +data class BuildingBlockPluginMappingUsage( + val mappingKey: String, + val buildingBlockDefinitionKey: String, + val processLinkId: UUID? = null, + val processDefinitionId: String? = null, + val activityId: String? = null, + val caseDefinitionKey: String? = null, + val caseDefinitionVersionTag: String? = null, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt new file mode 100644 index 0000000000..6493eb5953 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valueresolver.ValueResolverService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * Writes a plugin action's JSON result back to process variables / the case (or building-block) + * document through [PluginActionResultMapping]s, reusing the same split-and-dispatch shape as + * `BuildingBlockCallActivityListener.onCallActivityEnd` (`pv:` targets go to the process instance, + * everything else to `execution`'s business-key document — inside a building-block process that is + * the BB instance document, which BB output mappings then sync to the case as usual). + */ +@Component +@SkipComponentScan +class PluginActionResultHandler( + private val valueResolverService: ValueResolverService, + private val objectMapper: ObjectMapper, +) { + + /** + * @param execution the execution the action ran on; its business key (if present) is the + * document targeted by non-`pv:` mappings. + * @param result the action's return value, already serialized to JSON — `null` is a no-op + * except for a warning when mappings were configured (author error: an action that can + * return nothing was wired to a result mapping). + */ + fun handle(execution: DelegateExecution, result: JsonNode?, mappings: List) { + if (mappings.isEmpty()) { + return + } + if (result == null || result.isNull || result.isMissingNode) { + logger.warn { + "Plugin action for activity '${execution.currentActivityId}' of process instance " + + "'${execution.processInstanceId}' has ${mappings.size} result mapping(s) configured, " + + "but returned no result to map." + } + return + } + + val valuesToHandle = mappings.mapNotNull { mapping -> + val pointer = mapping.source.ifBlank { "" } + val node = result.at(pointer) + if (node.isMissingNode) { + logger.warn { + "Plugin action result mapping source pointer '${mapping.source}' was absent " + + "in the action result for activity '${execution.currentActivityId}' " + + "of process instance '${execution.processInstanceId}' — target " + + "'${mapping.target}' was not written." + } + return@mapNotNull null + } + // JSON null is a value, not an absence: the plugin explicitly returned null for this + // key, so it is written through to the target (which may clear an existing value). + mapping.target to objectMapper.treeToValue(node, Any::class.java) + }.toMap() + + if (valuesToHandle.isEmpty()) { + return + } + + val pvTargets = valuesToHandle.filter { it.key.startsWith("pv:") } + val otherTargets = valuesToHandle.filter { !it.key.startsWith("pv:") } + + if (pvTargets.isNotEmpty()) { + valueResolverService.handleValues(execution.processInstanceId, execution, pvTargets) + } + + if (otherTargets.isNotEmpty()) { + val businessKey = execution.processBusinessKey + ?: error( + "Cannot write plugin action result mappings for activity " + + "'${execution.currentActivityId}' — process instance " + + "'${execution.processInstanceId}' has no business-key document." + ) + valueResolverService.handleValues(UUID.fromString(businessKey), otherTargets) + } + } + + private companion object { + val logger = KotlinLogging.logger {} + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt new file mode 100644 index 0000000000..e0f9f4f223 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valueresolver.exception.ValueResolverValidationException + +/** + * Save-time guard shared by the embedded ([com.ritense.plugin.domain.PluginProcessLink]) and + * external process-link mappers. Only `doc:`, `pv:` and `case:` targets support writes today + * (mirrors `ValueResolverService.handleValues`'s actual capabilities — `zaak:` and friends are + * read-only resolvers); anything else is rejected before it reaches the database so authors get + * immediate feedback instead of a silent no-op at process-run time. + */ +object PluginActionResultMappingValidator { + + private val WRITABLE_PREFIXES = setOf("doc", "pv", "case") + + fun validate(mappings: List) { + mappings.forEach { mapping -> + val prefix = mapping.target.substringBefore(":", missingDelimiterValue = "") + if (prefix !in WRITABLE_PREFIXES) { + throw ValueResolverValidationException( + "Action result mapping target '${mapping.target}' is not writable — only " + + "${WRITABLE_PREFIXES.joinToString { "'$it:'" }} targets are supported." + ) + } + } + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginConfigurationUsageResolver.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginConfigurationUsageResolver.kt new file mode 100644 index 0000000000..f09eddbfde --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginConfigurationUsageResolver.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import com.ritense.plugin.domain.PluginConfigurationId +import com.ritense.plugin.repository.PluginConfigurationRepository +import com.ritense.plugin.repository.PluginProcessLinkRepositoryImpl +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * In-use guard data source for embedded plugin configurations. Mirrors the external-plugin + * resolver: returns one [PluginUsageDto] per `PluginProcessLink` that references the configuration + * by its UUID. BUILDING_BLOCK references resolve dynamically per building-block context and + * are stored with `plugin_configuration_id = NULL`, so they are correctly excluded by + * `findByPluginConfigurationId` — only FIXED references block deletion of a specific + * configuration. + */ +@Service +@SkipComponentScan +@Transactional(readOnly = true) +@Suppress("DEPRECATION") +class PluginConfigurationUsageResolver( + private val pluginConfigurationRepository: PluginConfigurationRepository, + private val pluginProcessLinkRepository: PluginProcessLinkRepositoryImpl, + private val metaResolver: ProcessDefinitionUsageMetaResolver, +) { + + fun findUsagesForConfiguration(configurationId: PluginConfigurationId): List { + val configuration = pluginConfigurationRepository.findById(configurationId).orElse(null) + ?: return emptyList() + val links = pluginProcessLinkRepository.findByPluginConfigurationId(configurationId) + if (links.isEmpty()) return emptyList() + + val metaCache = mutableMapOf() + return links.map { link -> + val meta = metaCache.getOrPut(link.processDefinitionId) { + metaResolver.resolveMeta(link.processDefinitionId) + } + PluginUsageDto( + configurationId = configurationId.id, + configurationTitle = configuration.title, + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = link.processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = link.activityId, + activityName = metaResolver.resolveActivityName(meta, link.activityId), + processLinkId = link.id, + ) + } + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt index 22b5fb9ae5..215d6efbe1 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt @@ -44,6 +44,7 @@ import com.ritense.plugin.events.PluginConfigurationCreatedEvent import com.ritense.plugin.events.PluginConfigurationDeletedEvent import com.ritense.plugin.events.PluginConfigurationIdUpdatedEvent import com.ritense.plugin.events.PluginConfigurationUpdatedEvent +import com.ritense.plugin.exception.PluginConfigurationInUseException import com.ritense.plugin.exception.PluginEventInvocationException import com.ritense.plugin.exception.PluginPropertyParseException import com.ritense.plugin.exception.PluginPropertyRequiredException @@ -52,6 +53,7 @@ import com.ritense.plugin.repository.PluginConfigurationRepository import com.ritense.plugin.repository.PluginConfigurationSearchRepository import com.ritense.plugin.repository.PluginDefinitionRepository import com.ritense.plugin.repository.PluginProcessLinkRepository +import com.ritense.plugin.web.rest.dto.PluginUsageDto import com.ritense.plugin.web.rest.request.PluginProcessLinkCreateDto import com.ritense.plugin.web.rest.request.PluginProcessLinkUpdateDto import com.ritense.plugin.web.rest.result.PluginActionDefinitionDto @@ -100,7 +102,9 @@ class PluginService( private val encryptionService: EncryptionService, private val environment: Environment, private val caseDefinitionChecker: CaseDefinitionChecker, - private val buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver? + private val buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, + private val pluginConfigurationUsageResolver: PluginConfigurationUsageResolver, + private val pluginActionResultHandler: PluginActionResultHandler, ) { fun getObjectMapper(): ObjectMapper { @@ -284,21 +288,33 @@ class PluginService( return savedPluginConfiguration } + @Transactional(readOnly = true) + fun findPluginConfigurationUsages( + @LoggableResource(resourceType = PluginConfiguration::class) pluginConfigurationId: PluginConfigurationId + ): List = pluginConfigurationUsageResolver.findUsagesForConfiguration(pluginConfigurationId) + fun deletePluginConfiguration( @LoggableResource(resourceType = PluginConfiguration::class) pluginConfigurationId: PluginConfigurationId ) { - pluginConfigurationRepository.findByIdOrNull(pluginConfigurationId) - ?.let { - try { - it.runAllPluginEvents(EventType.DELETE) - } catch (_: Exception) { - logger.warn { "Failed to run events on plugin ${it.title} with id ${it.id.id}" } - } - - pluginConfigurationRepository.deleteById(pluginConfigurationId) - applicationEventPublisher.publishEvent(PluginConfigurationDeletedEvent(it)) + val configuration = pluginConfigurationRepository.findByIdOrNull(pluginConfigurationId) + ?: run { + logger.warn { "Plugin configuration with Id: [$pluginConfigurationId] was not found." } + return } - ?: logger.warn { "Plugin configuration with Id: [$pluginConfigurationId] was not found." } + + val usages = pluginConfigurationUsageResolver.findUsagesForConfiguration(pluginConfigurationId) + if (usages.isNotEmpty()) { + throw PluginConfigurationInUseException(pluginConfigurationId.id, usages) + } + + try { + configuration.runAllPluginEvents(EventType.DELETE) + } catch (_: Exception) { + logger.warn { "Failed to run events on plugin ${configuration.title} with id ${configuration.id.id}" } + } + + pluginConfigurationRepository.deleteById(pluginConfigurationId) + applicationEventPublisher.publishEvent(PluginConfigurationDeletedEvent(configuration)) } fun getPluginDefinitionActions( @@ -489,7 +505,9 @@ class PluginService( logger.debug { "Invoking method ${method.name} of class ${instance.javaClass.simpleName} for activity ${execution.currentActivityId} of process-instance ${execution.processInstanceId}" } - method.invoke(instance, *methodArguments) + val result = method.invoke(instance, *methodArguments) + applyActionResultMappings(execution, processLink, result) + result } } @@ -511,8 +529,23 @@ class PluginService( logger.debug { "Invoking method ${method.name} of class ${instance.javaClass.simpleName} for task ${task.taskDefinitionKey} of process-instance ${task.processInstanceId}" } - method.invoke(instance, *methodArguments) + val result = method.invoke(instance, *methodArguments) + applyActionResultMappings(task.execution, processLink, result) + result + } + } + + /** + * Covers every listener that calls [invoke] (service task, user task create, call activity, + * send/receive/intermediate events) with zero listener changes — the return value a + * `@PluginAction` method produces was discarded here before result mappings existed. + */ + private fun applyActionResultMappings(execution: DelegateExecution, processLink: PluginProcessLink, result: Any?) { + if (processLink.actionResultMappings.isEmpty()) { + return } + val resultNode = result?.let { objectMapper.valueToTree(it) } + pluginActionResultHandler.handle(execution, resultNode, processLink.actionResultMappings) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/ProcessDefinitionUsageMetaResolver.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/ProcessDefinitionUsageMetaResolver.kt new file mode 100644 index 0000000000..152a58dce9 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/ProcessDefinitionUsageMetaResolver.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import com.ritense.plugin.web.rest.dto.PluginUsageParentType +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.model.bpmn.BpmnModelInstance +import org.operaton.bpm.model.bpmn.instance.FlowElement +import org.springframework.stereotype.Component + +/** + * Reads everything the in-use guards (embedded and external) need to know about a process + * definition behind a process-link reference: its key/name, what case-definition or building- + * block owns it (parsed from the Operaton `versionTag` via + * [OperatonProcessDefinition.getBlueprintId]), and lazily, the BPMN model so the activity name + * can be resolved. + * + * Operaton lookups are wrapped in `runCatching` so a missing or unloadable process definition + * degrades to nullable fields — the row still surfaces with `processDefinitionId` and the link + * id, so the admin can investigate manually. + */ +@Component +@SkipComponentScan +class ProcessDefinitionUsageMetaResolver( + private val operatonRepositoryService: OperatonRepositoryService, + private val bpmnRepositoryService: RepositoryService, +) { + + fun resolveMeta(processDefinitionId: String): ProcessDefinitionUsageMeta { + val processDefinition: OperatonProcessDefinition? = runCatching { + operatonRepositoryService.findProcessDefinitionById(processDefinitionId) + }.getOrNull() + + val (parentType, parentKey, parentVersionTag) = classifyParent(processDefinition) + + return ProcessDefinitionUsageMeta( + processDefinitionKey = processDefinition?.key, + processDefinitionName = processDefinition?.name, + parentType = parentType, + parentKey = parentKey, + parentVersionTag = parentVersionTag, + bpmnModelLoader = { + runCatching { bpmnRepositoryService.getBpmnModelInstance(processDefinitionId) }.getOrNull() + }, + ) + } + + fun resolveActivityName(meta: ProcessDefinitionUsageMeta, activityId: String): String? { + val model = meta.bpmnModel ?: return null + return runCatching { + model.getModelElementById(activityId)?.name + }.getOrNull() + } + + private fun classifyParent( + processDefinition: OperatonProcessDefinition?, + ): Triple { + return when (val blueprint = processDefinition?.getBlueprintId()) { + is CaseDefinitionId -> Triple(PluginUsageParentType.CASE, blueprint.key, blueprint.versionTag.toString()) + is BuildingBlockDefinitionId -> Triple(PluginUsageParentType.BUILDING_BLOCK, blueprint.key, blueprint.versionTag.toString()) + else -> Triple(PluginUsageParentType.GLOBAL, null, null) + } + } +} + +class ProcessDefinitionUsageMeta( + val processDefinitionKey: String?, + val processDefinitionName: String?, + val parentType: PluginUsageParentType, + val parentKey: String?, + val parentVersionTag: String?, + bpmnModelLoader: () -> BpmnModelInstance?, +) { + val bpmnModel: BpmnModelInstance? by lazy(bpmnModelLoader) +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginConfigurationResource.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginConfigurationResource.kt index 39d9b8d397..6c71fb4216 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginConfigurationResource.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginConfigurationResource.kt @@ -22,6 +22,7 @@ import com.ritense.plugin.domain.PluginConfiguration import com.ritense.plugin.domain.PluginConfigurationId import com.ritense.plugin.service.PluginConfigurationSearchParameters import com.ritense.plugin.service.PluginService +import com.ritense.plugin.web.rest.dto.PluginUsageDto import com.ritense.plugin.web.rest.request.CreatePluginConfigurationDto import com.ritense.plugin.web.rest.request.UpdatePluginConfigurationDto import com.ritense.plugin.web.rest.result.PluginConfigurationDto @@ -29,6 +30,7 @@ import com.ritense.plugin.web.rest.result.PluginConfigurationExportDto import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.operaton.bpm.engine.repository.ProcessDefinition import org.springframework.http.ResponseEntity @@ -51,6 +53,10 @@ class PluginConfigurationResource( private var pluginService: PluginService ) { + @EndpointDescription( + en = "List plugin configurations", + nl = "Pluginconfiguraties ophalen", + ) @GetMapping("/v1/plugin/configuration") fun getPluginDefinitions( @LoggableResource(resourceType = ProcessDefinition::class) @RequestParam("pluginDefinitionKey") pluginDefinitionKey: String?, @@ -72,6 +78,10 @@ class PluginConfigurationResource( .map { PluginConfigurationDto(it) }) } + @EndpointDescription( + en = "Create plugin configuration", + nl = "Pluginconfiguratie aanmaken", + ) @PostMapping("/v1/plugin/configuration") fun createPluginConfiguration( @Valid @RequestBody createPluginConfiguration: CreatePluginConfigurationDto @@ -96,6 +106,10 @@ class PluginConfigurationResource( } } + @EndpointDescription( + en = "Update plugin configuration by id", + nl = "Pluginconfiguratie bijwerken op id", + ) @PutMapping("/v1/plugin/configuration/{pluginConfigurationId}") fun updatePluginConfiguration( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: UUID, @@ -119,6 +133,10 @@ class PluginConfigurationResource( ) } + @EndpointDescription( + en = "Export plugin configurations", + nl = "Pluginconfiguraties exporteren", + ) @GetMapping("/v1/plugin/configuration/export") fun exportPluginConfiguration(): ResponseEntity> { val pluginConfigurations = pluginService.getPluginConfigurations(PluginConfigurationSearchParameters()) @@ -132,6 +150,10 @@ class PluginConfigurationResource( return ResponseEntity.ok(pluginConfigurations) } + @EndpointDescription( + en = "Delete plugin configuration by id", + nl = "Pluginconfiguratie verwijderen op id", + ) @DeleteMapping("/v1/plugin/configuration/{pluginConfigurationId}") fun deletePluginConfiguration( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: UUID @@ -139,4 +161,18 @@ class PluginConfigurationResource( pluginService.deletePluginConfiguration(PluginConfigurationId.existingId(pluginConfigurationId)) return ResponseEntity.noContent().build() } + + /** + * Lets the management UI pre-emptively disable the delete control with the same payload + * the backend would attach to a 409 from `DELETE /v1/plugin/configuration/{id}`. + */ + @EndpointDescription( + en = "List plugin configuration usages by id", + nl = "Gebruik van pluginconfiguratie ophalen op id", + ) + @GetMapping("/v1/plugin/configuration/{pluginConfigurationId}/usages") + fun listPluginConfigurationUsages( + @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: UUID, + ): ResponseEntity> = + ResponseEntity.ok(pluginService.findPluginConfigurationUsages(PluginConfigurationId.existingId(pluginConfigurationId))) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginDefinitionResource.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginDefinitionResource.kt index 1ff8c1b85d..ce905a1a16 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginDefinitionResource.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/PluginDefinitionResource.kt @@ -23,6 +23,7 @@ import com.ritense.plugin.web.rest.result.PluginActionDefinitionDto import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -37,6 +38,10 @@ class PluginDefinitionResource( private var pluginService: PluginService ) { + @EndpointDescription( + en = "List plugin definitions", + nl = "Plugindefinities ophalen", + ) @GetMapping("/v1/plugin/definition") fun getPluginDefinitions( @RequestParam(value = "activityType", required = false) activityType: ActivityTypeWithEventName? @@ -44,6 +49,10 @@ class PluginDefinitionResource( return ResponseEntity.ok(pluginService.getPluginDefinitions(activityType)) } + @EndpointDescription( + en = "List plugin definition actions by key", + nl = "Plugindefinitie-acties ophalen op sleutel", + ) @GetMapping("/v1/plugin/definition/{pluginDefinitionKey}/action") fun getPluginDefinitionActions( @LoggableResource(resourceType = PluginDefinition::class) @PathVariable pluginDefinitionKey: String, diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt new file mode 100644 index 0000000000..65a5bc655a --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.web.rest.dto + +import java.util.UUID + +/** + * What owns the process definition that a [PluginUsageDto] sits on. `GLOBAL` doubles as the + * fallback when the process definition can't be loaded — in that case `parentKey` and + * `parentVersionTag` are both null and the UI degrades gracefully to the raw + * `processDefinitionId`. + */ +enum class PluginUsageParentType { + CASE, + BUILDING_BLOCK, + GLOBAL, +} + +/** + * One usage of a plugin configuration that blocks its deletion. Used by the "configuration in use" + * and "host in use" guards on both the embedded and external plugin paths. + * + * Several shapes share this DTO: + * - **Process-link usage** (embedded + external): the BPMN-activity fields are populated and + * [tabKey] is null. + * - **External-plugin case-tab usage**: [tabKey]/[tabName] are populated, [parentType] is `CASE`, + * and the process-link fields are null. (A `case-tab` of an external plugin references the + * configuration but has no process link.) + * - **External-plugin case-widget usage**: like the case-tab usage — [tabKey]/[tabName] identify the + * owning WIDGETS tab and [widgetKey] the widget within it. (An `external-plugin` widget references + * the configuration but has no process link.) + * - **Building-block mapping usage**: a building block's `pluginConfigurationMappings` reference + * the configuration; [buildingBlockKey] names the building block. On a call-activity link the + * process-link fields are populated too; on a case-definition ↔ BB link only [parentKey]/ + * [parentVersionTag] (the case) are. + * - **Definition-reference usage** (host deletion only): a `BUILDING_BLOCK`-reference process link + * pins a plugin *definition* rather than a configuration — [configurationId] then carries the + * definition id and [configurationTitle] the pinned `pluginId@version` pair. + */ +data class PluginUsageDto( + val configurationId: UUID, + val configurationTitle: String, + val parentType: PluginUsageParentType, + val parentKey: String?, + val parentVersionTag: String?, + val processDefinitionId: String? = null, + val processDefinitionKey: String? = null, + val processDefinitionName: String? = null, + val activityId: String? = null, + val activityName: String? = null, + val processLinkId: UUID? = null, + val tabKey: String? = null, + val tabName: String? = null, + val buildingBlockKey: String? = null, + val widgetKey: String? = null, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt index 460a377c84..1c5b105b24 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt @@ -18,6 +18,7 @@ package com.ritense.plugin.web.rest.request import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -34,6 +35,7 @@ data class PluginProcessLinkCreateDto( override val activityType: ActivityTypeWithEventName, val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkCreateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt index e40fbe8b0c..a1266d4d81 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt @@ -18,6 +18,7 @@ package com.ritense.plugin.web.rest.request import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto @@ -30,7 +31,8 @@ data class PluginProcessLinkUpdateDto( val pluginActionDefinitionKey: String, val actionProperties: ObjectNode? = null, val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, - val pluginDefinitionKey: String? = null + val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkUpdateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt index 4e6d044be5..a971899b3e 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt @@ -22,7 +22,22 @@ data class PluginDefinitionsWithDependenciesDto( val plugins: List ) +/** + * [source] discriminates embedded plugin definitions (identified by [pluginDefinitionKey] alone, + * unversioned) from external plugin definitions referenced via a `BUILDING_BLOCK` + * `PluginConfigurationReference` (identified by [pluginDefinitionKey] == `pluginId` + + * [pluginDefinitionVersion]). Defaults to [PluginRequirementSource.EMBEDDED] and leaves + * [pluginDefinitionVersion] `null` so existing frontend consumers built against the embedded-only + * shape keep working unchanged. + */ data class PluginWithDependenciesDto( val pluginDefinitionKey: String, - val dependencies: List -) \ No newline at end of file + val dependencies: List, + val source: PluginRequirementSource = PluginRequirementSource.EMBEDDED, + val pluginDefinitionVersion: String? = null, +) + +enum class PluginRequirementSource { + EMBEDDED, + EXTERNAL, +} \ No newline at end of file diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt index ecee285820..8282ea3007 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt @@ -17,6 +17,7 @@ package com.ritense.plugin.web.rest.result import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -33,5 +34,6 @@ data class PluginProcessLinkResultDto( val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, val pluginActionDefinitionKey: String, - val actionProperties: ObjectNode? = null + val actionProperties: ObjectNode? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkResponseDto diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/ExternalPluginProcessLinkStandIn.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/ExternalPluginProcessLinkStandIn.kt new file mode 100644 index 0000000000..6cda5392b3 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/ExternalPluginProcessLinkStandIn.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.domain + +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import java.util.UUID + +/** + * Test-only stand-in for `ExternalPluginProcessLink`: an STI sibling of [PluginProcessLink] that + * also embeds [PluginConfigurationReference] on the same shared columns (`reference_type`, + * `plugin_definition_key`, `plugin_definition_version`). Exists solely so + * [PluginConfigurationReferenceSharedStiColumnsTest] can build Hibernate metadata for two siblings sharing + * the embeddable without this module depending on the real + * `com.ritense.externalplugin.domain.ExternalPluginProcessLink` in `:backend:external-plugin`. + */ +@Entity +@DiscriminatorValue("_test_external_plugin_stand_in") +class ExternalPluginProcessLinkStandIn( + id: UUID, + processDefinitionId: String, + activityId: String, + activityType: ActivityTypeWithEventName, + + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), +) : ProcessLink( + id, + processDefinitionId, + activityId, + activityType, + "_test_external_plugin_stand_in", +) { + override fun copy(id: UUID, processDefinitionId: String) = ExternalPluginProcessLinkStandIn( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = activityType, + pluginConfigurationReference = pluginConfigurationReference, + ) +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceSharedStiColumnsTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceSharedStiColumnsTest.kt new file mode 100644 index 0000000000..ea4a5d6e7a --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceSharedStiColumnsTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.domain + +import org.assertj.core.api.Assertions.assertThat +import org.hibernate.boot.MetadataSources +import org.hibernate.boot.registry.StandardServiceRegistryBuilder +import org.hibernate.cfg.AvailableSettings +import org.hibernate.dialect.PostgreSQLDialect +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.hibernate.boot.registry.StandardServiceRegistry + +/** + * Verifies that Hibernate accepts the shared [PluginConfigurationReference] embeddable — mapping + * columns `reference_type` / `plugin_definition_key` / `plugin_definition_version` — being embedded + * by *two* single-table-inheritance siblings of `process_link`: [PluginProcessLink] (existing) and a + * stand-in for the planned `ExternalPluginProcessLink` rework ([ExternalPluginProcessLinkStandIn] + * below, a minimal local copy so this test does not depend on the not-yet-changed real entity). + * + * `buildSessionFactory()` (not just `buildMetadata()`) is exercised — no DataSource/connection is + * opened, `hibernate.dialect` is set explicitly so boot does not try to auto-detect one from a + * (non-existent) connection — because building the mapping model / persisters is where Hibernate + * 6 actually validates column consistency, not metadata collection alone. + * + * Result: mapping the same three columns from the same embeddable on both STI siblings builds + * cleanly — Hibernate does not consider this a conflict, because the two entities never both + * populate the same row (discriminated by `process_link_type`); the embeddable is simply reused as + * a value type on each. This was cross-checked with two negative controls during the spike (not + * committed, since they'd otherwise permanently fail the build): + * - Mapping `reference_type` a second time as a plain column *within the same entity* (alongside + * the embeddable) → Hibernate does throw: `MappingException: Column 'reference_type' is + * duplicated in mapping for entity ...`. This confirms genuine duplicate-column mappings within + * one entity are still caught, i.e. the harness is not silently permissive by construction. + * - Mapping `reference_type` as a `String` on one sibling while [PluginConfigurationReference] + * maps it as an enum on another sibling → still no error. Hibernate does not cross-validate + * column types between sibling STI subclasses at boot; that would only surface against a real + * schema (`hbm2ddl.auto=validate`) or at query/flush time for whichever subclass mismaps it. + * Not a concern here because both `PluginProcessLink` and the reworked `ExternalPluginProcessLink` + * will map the *same* embeddable type with *identical* column definitions — there is no + * divergence to catch. + * + * Conclusion: the shared-column design in D1 is safe to implement as specified. The plan's fallback + * (distinct external column names, e.g. `external_plugin_reference_type`) is **not needed**. + */ +class PluginConfigurationReferenceSharedStiColumnsTest { + + private var registry: StandardServiceRegistry? = null + + @AfterEach + fun tearDown() { + registry?.let { StandardServiceRegistryBuilder.destroy(it) } + } + + @Test + fun `two STI siblings can both embed PluginConfigurationReference on the shared columns`() { + registry = StandardServiceRegistryBuilder() + .applySetting(AvailableSettings.DIALECT, PostgreSQLDialect::class.java.name) + // No JDBC connection is opened for buildMetadata(); this only avoids Hibernate + // trying (and failing) to reach out to a ConnectionProvider for dialect resolution. + .applySetting(AvailableSettings.CONNECTION_PROVIDER_DISABLES_AUTOCOMMIT, "false") + .build() + + val metadataSources = MetadataSources(registry) + .addAnnotatedClass(com.ritense.processlink.domain.ProcessLink::class.java) + .addAnnotatedClass(PluginProcessLink::class.java) + .addAnnotatedClass(ExternalPluginProcessLinkStandIn::class.java) + + val metadata = metadataSources.buildMetadata() + // buildSessionFactory (not just buildMetadata) triggers the persister/mapping-model build, + // which is where Hibernate 6 actually validates per-table column consistency across STI + // subclasses (buildMetadata alone does not). + val sessionFactory = metadata.buildSessionFactory() + sessionFactory.close() + + val processLinkBinding = metadata.getEntityBinding("com.ritense.processlink.domain.ProcessLink") + assertThat(processLinkBinding).isNotNull + + // Both siblings' persistent classes exist and Hibernate could resolve the shared columns + // without throwing — the true assertion is that buildSessionFactory() above did not raise. + assertThat(metadata.getEntityBinding(PluginProcessLink::class.java.name)).isNotNull + assertThat(metadata.getEntityBinding(ExternalPluginProcessLinkStandIn::class.java.name)).isNotNull + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt index 60d4d9994c..517fbd360e 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt @@ -71,4 +71,23 @@ class PluginConfigurationReferenceTest { ) }.doesNotThrowAnyException() } + + @Test + fun `pluginDefinitionVersion defaults to null`() { + val ref = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "zaken-api", + ) + assertThat(ref.pluginDefinitionVersion).isNull() + } + + @Test + fun `pluginDefinitionVersion can be set for external plugin usage`() { + val ref = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ) + assertThat(ref.pluginDefinitionVersion).isEqualTo("1.2.3") + } } diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt new file mode 100644 index 0000000000..b40688b230 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.service + +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valueresolver.ValueResolverService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.delegate.DelegateExecution +import java.util.UUID + +class PluginActionResultHandlerTest { + + private lateinit var valueResolverService: ValueResolverService + private lateinit var handler: PluginActionResultHandler + private lateinit var execution: DelegateExecution + + @BeforeEach + fun init() { + valueResolverService = mock() + handler = PluginActionResultHandler(valueResolverService, MapperSingleton.get()) + execution = mock() + whenever(execution.processInstanceId).thenReturn("process-instance-1") + whenever(execution.currentActivityId).thenReturn("activity-1") + } + + @Test + fun `does nothing when no mappings are configured`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + + handler.handle(execution, result, emptyList()) + + verify(valueResolverService, never()).handleValues(any(), any(), any()) + verify(valueResolverService, never()).handleValues(any(), any()) + } + + @Test + fun `extracts the source pointer and writes it to the target`() { + val result = MapperSingleton.get().readTree("""{"value": 123, "nested": {"field": "abc"}}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/nested/field", target = "doc:/summary")) + ) + + verify(valueResolverService).handleValues(eq(businessKey), eq(mapOf("doc:/summary" to "abc"))) + } + + @Test + fun `an empty source pointer selects the whole result`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "", target = "doc:/whole")) + ) + + verify(valueResolverService).handleValues( + eq(businessKey), + org.mockito.kotlin.check { values -> + val written = values["doc:/whole"] + org.assertj.core.api.Assertions.assertThat(written).isInstanceOf(Map::class.java) + } + ) + } + + @Test + fun `splits pv target from document targets across two handleValues calls`() { + val result = MapperSingleton.get().readTree("""{"a": 1, "b": 2}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf( + PluginActionResultMapping(source = "/a", target = "pv:varA"), + PluginActionResultMapping(source = "/b", target = "doc:/fieldB"), + ) + ) + + verify(valueResolverService).handleValues("process-instance-1", execution, mapOf("pv:varA" to 1)) + verify(valueResolverService).handleValues(businessKey, mapOf("doc:/fieldB" to 2)) + } + + @Test + fun `logs a warning and skips the target when the source pointer does not match`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + whenever(execution.processBusinessKey).thenReturn(UUID.randomUUID().toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/missing", target = "doc:/summary")) + ) + + verify(valueResolverService, never()).handleValues(any(), any()) + } + + @Test + fun `writes null result values through to the target instead of skipping them`() { + val result = MapperSingleton.get().readTree("""{"remarks": null, "decision": "APPROVED"}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf( + PluginActionResultMapping(source = "/remarks", target = "doc:/reviewerRemarks"), + PluginActionResultMapping(source = "/decision", target = "doc:/approvalDecision"), + ) + ) + + verify(valueResolverService).handleValues( + businessKey, + mapOf("doc:/reviewerRemarks" to null, "doc:/approvalDecision" to "APPROVED") + ) + } + + @Test + fun `logs a warning and does not fail the process when the result is null but mappings are configured`() { + handler.handle( + execution, + null, + listOf(PluginActionResultMapping(source = "/value", target = "doc:/summary")) + ) + + verify(valueResolverService, never()).handleValues(any(), any()) + verify(valueResolverService, never()).handleValues(any(), any(), any()) + } + + @Test + fun `throws when a non-pv target is configured but the execution has no business key`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + whenever(execution.processBusinessKey).thenReturn(null) + + val exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) { + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/value", target = "doc:/summary")) + ) + } + org.assertj.core.api.Assertions.assertThat(exception.message).contains("business-key document") + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt index c86f502bec..c70388b7e2 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt @@ -23,6 +23,7 @@ import com.ritense.plugin.annotation.PluginAction import com.ritense.plugin.annotation.PluginActionProperty import com.ritense.plugin.domain.PluginActionDefinition import com.ritense.plugin.domain.PluginActionDefinitionId +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfiguration import com.ritense.plugin.domain.PluginConfigurationId import com.ritense.plugin.domain.PluginConfigurationReference @@ -33,6 +34,7 @@ import com.ritense.plugin.domain.PluginProperty import com.ritense.plugin.events.PluginConfigurationCreatedEvent import com.ritense.plugin.events.PluginConfigurationDeletedEvent import com.ritense.plugin.events.PluginConfigurationUpdatedEvent +import com.ritense.plugin.exception.PluginConfigurationInUseException import com.ritense.plugin.exception.PluginEventInvocationException import com.ritense.plugin.exception.PluginPropertyParseException import com.ritense.plugin.exception.PluginPropertyRequiredException @@ -41,6 +43,8 @@ import com.ritense.plugin.repository.PluginConfigurationRepository import com.ritense.plugin.repository.PluginConfigurationSearchRepository import com.ritense.plugin.repository.PluginDefinitionRepository import com.ritense.plugin.repository.PluginProcessLinkRepository +import com.ritense.plugin.web.rest.dto.PluginUsageDto +import com.ritense.plugin.web.rest.dto.PluginUsageParentType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.contract.json.MapperSingleton import com.ritense.valueresolver.ValueResolverService @@ -79,6 +83,8 @@ internal class PluginServiceTest { lateinit var applicationEventPublisher: ApplicationEventPublisher lateinit var encryptionService: EncryptionService lateinit var environment: Environment + lateinit var pluginConfigurationUsageResolver: PluginConfigurationUsageResolver + lateinit var pluginActionResultHandler: PluginActionResultHandler @BeforeEach fun init() { @@ -92,6 +98,8 @@ internal class PluginServiceTest { applicationEventPublisher = mock() encryptionService = mock() environment = mock() + pluginConfigurationUsageResolver = mock() + pluginActionResultHandler = mock() pluginService = spy(PluginService( pluginDefinitionRepository = pluginDefinitionRepository, pluginConfigurationRepository = pluginConfigurationRepository, @@ -106,7 +114,9 @@ internal class PluginServiceTest { encryptionService = encryptionService, environment = environment, caseDefinitionChecker = mock(), - buildingBlockPluginConfigurationResolver = null + buildingBlockPluginConfigurationResolver = null, + pluginConfigurationUsageResolver = pluginConfigurationUsageResolver, + pluginActionResultHandler = pluginActionResultHandler, )) } @@ -334,10 +344,10 @@ internal class PluginServiceTest { plugin2.name = "whatever" // need to mock findById because findByIdOrNull can't be mocked because it's static - whenever(pluginConfigurationRepository.findById(any())) - .thenReturn(Optional.of(pluginConfiguration)) - doReturn(plugin2) - .whenever(pluginService).createInstance(any()) + whenever(pluginConfigurationRepository.findById(any())).thenReturn(Optional.of(pluginConfiguration)) + whenever(pluginConfigurationUsageResolver.findUsagesForConfiguration(pluginConfigurationId)) + .thenReturn(emptyList()) + doReturn(plugin2).whenever(pluginService).createInstance(any()) pluginService.deletePluginConfiguration(pluginConfigurationId) @@ -346,6 +356,66 @@ internal class PluginServiceTest { assertEquals(pluginConfiguration, deleteEventCaptor.firstValue.pluginConfiguration) } + @Test + fun `should throw when deleting a configuration that is still referenced`() { + val pluginDefinition = newPluginDefinition() + addPluginProperty(pluginDefinition) + val pluginConfiguration = newPluginConfiguration(pluginDefinition) + val pluginConfigurationId = pluginConfiguration.id + + whenever(pluginConfigurationRepository.findById(any())).thenReturn(Optional.of(pluginConfiguration)) + val usages = listOf( + PluginUsageDto( + configurationId = pluginConfigurationId.id, + configurationTitle = pluginConfiguration.title!!, + parentType = PluginUsageParentType.CASE, + parentKey = "complaint", + parentVersionTag = "1.0.0", + processDefinitionId = "complaint-intake:3:abc", + processDefinitionKey = "complaint-intake", + processDefinitionName = "Complaint intake", + activityId = "SendLetter", + activityName = "Send letter to citizen", + processLinkId = UUID.randomUUID(), + ) + ) + whenever(pluginConfigurationUsageResolver.findUsagesForConfiguration(pluginConfigurationId)) + .thenReturn(usages) + + assertThrows(PluginConfigurationInUseException::class.java) { + pluginService.deletePluginConfiguration(pluginConfigurationId) + } + + verify(pluginConfigurationRepository, org.mockito.kotlin.never()).deleteById(any()) + verify(applicationEventPublisher, org.mockito.kotlin.never()).publishEvent(any()) + } + + @Test + fun `findPluginConfigurationUsages delegates to the resolver`() { + val pluginConfigurationId = PluginConfigurationId.newId() + val expected = listOf( + PluginUsageDto( + configurationId = pluginConfigurationId.id, + configurationTitle = "x", + parentType = PluginUsageParentType.GLOBAL, + parentKey = null, + parentVersionTag = null, + processDefinitionId = "p:1:hash", + processDefinitionKey = null, + processDefinitionName = null, + activityId = "a", + activityName = null, + processLinkId = UUID.randomUUID(), + ) + ) + whenever(pluginConfigurationUsageResolver.findUsagesForConfiguration(pluginConfigurationId)) + .thenReturn(expected) + + val result = pluginService.findPluginConfigurationUsages(pluginConfigurationId) + + assertEquals(expected, result) + } + @Test fun `should get plugin action definitions from repository by key`(){ whenever(pluginActionDefinitionRepository.findByIdPluginDefinitionKey("test")) @@ -474,6 +544,71 @@ internal class PluginServiceTest { verify(testDependency).processInt(null) } + @Test + fun `should apply action result mappings when the link declares them`() { + val execution = mock() + val processLink = PluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process", + activityId = "activity", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionProperties = MapperSingleton.get().readTree("{\"test\":123}") as ObjectNode, + pluginConfigurationId = PluginConfigurationId.newId(), + pluginConfigurationReference = PluginConfigurationReference(), + pluginActionDefinitionKey = "test-action-with-result", + actionResultMappings = listOf(PluginActionResultMapping(source = "/value", target = "pv:result")), + ) + + val pluginDefinition = newPluginDefinition() + val pluginConfiguration = newPluginConfiguration(pluginDefinition) + val testDependency = mock() + + whenever(pluginConfigurationRepository.getReferenceById(any())).thenReturn(pluginConfiguration) + whenever(pluginFactory.canCreate(any())).thenReturn(true) + whenever(pluginFactory.create(any())).thenReturn(TestPlugin(testDependency)) + whenever(execution.processInstanceId).thenReturn("test") + whenever(valueResolverService.resolveValues(any(), any(), any())).thenReturn(mapOf("test" to 123)) + + pluginService.invoke(execution, processLink) + + val resultCaptor = argumentCaptor() + verify(pluginActionResultHandler).handle( + org.mockito.kotlin.eq(execution), + resultCaptor.capture(), + org.mockito.kotlin.eq(processLink.actionResultMappings), + ) + assertEquals(123, resultCaptor.firstValue.get("value").intValue()) + } + + @Test + fun `should not touch the result handler when the link declares no result mappings`() { + val execution = mock() + val processLink = PluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process", + activityId = "activity", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionProperties = MapperSingleton.get().readTree("{\"test\":123}") as ObjectNode, + pluginConfigurationId = PluginConfigurationId.newId(), + pluginConfigurationReference = PluginConfigurationReference(), + pluginActionDefinitionKey = "test-action-with-result", + ) + + val pluginDefinition = newPluginDefinition() + val pluginConfiguration = newPluginConfiguration(pluginDefinition) + val testDependency = mock() + + whenever(pluginConfigurationRepository.getReferenceById(any())).thenReturn(pluginConfiguration) + whenever(pluginFactory.canCreate(any())).thenReturn(true) + whenever(pluginFactory.create(any())).thenReturn(TestPlugin(testDependency)) + whenever(execution.processInstanceId).thenReturn("test") + whenever(valueResolverService.resolveValues(any(), any(), any())).thenReturn(mapOf("test" to 123)) + + pluginService.invoke(execution, processLink) + + verify(pluginActionResultHandler, org.mockito.kotlin.never()).handle(any(), any(), any()) + } + @Test fun `should throw exception when invoking delegateExecution method with resolved variable where result does not match argument type`(){ val execution = mock() @@ -525,7 +660,9 @@ internal class PluginServiceTest { encryptionService = encryptionService, environment = environment, caseDefinitionChecker = mock(), - buildingBlockPluginConfigurationResolver = resolver + buildingBlockPluginConfigurationResolver = resolver, + pluginConfigurationUsageResolver = pluginConfigurationUsageResolver, + pluginActionResultHandler = pluginActionResultHandler, ) ) @@ -589,7 +726,9 @@ internal class PluginServiceTest { encryptionService = encryptionService, environment = environment, caseDefinitionChecker = mock(), - buildingBlockPluginConfigurationResolver = resolver + buildingBlockPluginConfigurationResolver = resolver, + pluginConfigurationUsageResolver = pluginConfigurationUsageResolver, + pluginActionResultHandler = pluginActionResultHandler, ) ) @@ -814,8 +953,21 @@ internal class PluginServiceTest { fun doThing2(@PluginActionProperty test: Int?) { testDependency.processInt(test) } + + @PluginAction( + key = "test-action-with-result", + title = "Test action with result", + description = "This is an action used to verify result-mapping write-back", + activityTypes = [ActivityTypeWithEventName.SERVICE_TASK_START] + ) + fun doThingWithResult(@PluginActionProperty test: Int): TestActionResult { + testDependency.processInt(test) + return TestActionResult(test) + } } + data class TestActionResult(val value: Int) + class TestPlugin2 { @com.ritense.plugin.annotation.PluginProperty(key = "name", required = false, secret = false) var name: String? = null diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentAuditResource.java b/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentAuditResource.java index 8cb9db7e6e..d04fa74820 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentAuditResource.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentAuditResource.java @@ -24,6 +24,7 @@ import com.ritense.processdocument.service.ProcessDocumentAuditService; import com.ritense.valtimo.contract.annotation.SkipComponentScan; import com.ritense.valtimo.contract.audit.view.AuditView; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -44,6 +45,10 @@ public ProcessDocumentAuditResource(ProcessDocumentAuditService processDocumentA this.processDocumentAuditService = processDocumentAuditService; } + @EndpointDescription( + en = "Get process document audit log", + nl = "Auditlogregels van procesdocument ophalen" + ) @GetMapping("/v1/process-document/instance/document/{documentId}/audit") @JsonView(AuditView.Public.class) public ResponseEntity> getAuditLog( diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentResource.java b/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentResource.java index abeeb037e3..8f52991a5f 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentResource.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/web/rest/ProcessDocumentResource.java @@ -37,6 +37,7 @@ import com.ritense.processdocument.service.result.NewDocumentAndStartProcessResult; import com.ritense.valtimo.contract.annotation.SkipComponentScan; import com.ritense.valtimo.contract.case_.CaseDefinitionId; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import jakarta.validation.Valid; import java.util.List; import java.util.Optional; @@ -75,6 +76,10 @@ public ProcessDocumentResource( } @Deprecated(since = "13.x", forRemoval = true) + @EndpointDescription( + en = "List process links by case definition", + nl = "Proceskoppelingen per dossierdefinitie ophalen" + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/case-process-link") public ResponseEntity> findProcessDocumentDefinitions( @PathVariable(name = "caseDefinitionKey") String caseDefinitionKey, @@ -94,6 +99,10 @@ public ResponseEntity> findProcessDocument } @Deprecated(since = "13.x", forRemoval = true) + @EndpointDescription( + en = "List process links by document instance", + nl = "Proceskoppelingen per documentinstantie ophalen" + ) @GetMapping("/v1/document-instance/{documentId}/case-process-link") public ResponseEntity> findProcessDocumentDefinitions( @PathVariable(name = "documentId") UUID documentId, @@ -107,6 +116,10 @@ public ResponseEntity> findProcessDocument )); } + @EndpointDescription( + en = "Get process link by process instance", + nl = "Proceskoppeling per procesinstantie ophalen" + ) @GetMapping("/v1/process-instance/{processInstanceId}/case-process-link") public ResponseEntity getProcessDocumentDefinition( @PathVariable String processInstanceId @@ -117,6 +130,10 @@ public ResponseEntity getProcessDocumentDefinit } @Deprecated(since = "Since v13", forRemoval = true) + @EndpointDescription( + en = "List process document instances by document", + nl = "Procesdocumentinstanties per document ophalen" + ) @GetMapping("/v1/process-document/instance/document/{documentId}") public ResponseEntity> findProcessDocumentInstancesV1( @PathVariable UUID documentId @@ -125,6 +142,10 @@ public ResponseEntity> findProcessDocume processDocumentAssociationService.findProcessDocumentInstanceDtosWithoutBuildingBlocks(JsonSchemaDocumentId.existingId(documentId))); } + @EndpointDescription( + en = "List process document instances by document", + nl = "Procesdocumentinstanties per document ophalen" + ) @GetMapping("/v2/process-document/instance/document/{documentId}") public ResponseEntity> findProcessDocumentInstances( @PathVariable UUID documentId @@ -133,6 +154,10 @@ public ResponseEntity> findProcessDocume processDocumentAssociationService.findProcessDocumentInstanceDtos(JsonSchemaDocumentId.existingId(documentId))); } + @EndpointDescription( + en = "Create document and start process", + nl = "Document aanmaken en proces starten" + ) @PostMapping(value = "/v1/process-document/operation/new-document-and-start-process", consumes = APPLICATION_JSON_VALUE) public ResponseEntity newDocumentAndStartProcess( @Valid @RequestBody NewDocumentAndStartProcessRequest request @@ -142,6 +167,10 @@ public ResponseEntity newDocumentAndStartProce return ResponseEntity.status(httpStatus).body(result); } + @EndpointDescription( + en = "Modify document and complete task", + nl = "Document bijwerken en taak afronden" + ) @PostMapping(value = "/v1/process-document/operation/modify-document-and-complete-task", consumes = APPLICATION_JSON_VALUE) public ResponseEntity modifyDocumentAndCompleteTask( @Valid @RequestBody ModifyDocumentAndCompleteTaskRequest request @@ -151,6 +180,10 @@ public ResponseEntity modifyDocumentAndComp return ResponseEntity.status(httpStatus).body(result); } + @EndpointDescription( + en = "Modify document and start process", + nl = "Document bijwerken en proces starten" + ) @PostMapping(value = "/v1/process-document/operation/modify-document-and-start-process", consumes = APPLICATION_JSON_VALUE) public ResponseEntity modifyDocumentAndStartProcess( @Valid @RequestBody ModifyDocumentAndStartProcessRequest request diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/CaseDefinitionProcessManagementResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/CaseDefinitionProcessManagementResource.kt index 0de180953d..8b119ec089 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/CaseDefinitionProcessManagementResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/CaseDefinitionProcessManagementResource.kt @@ -24,6 +24,7 @@ import com.ritense.processdocument.service.CaseDefinitionProcessLinkService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -41,6 +42,10 @@ class CaseDefinitionProcessManagementResource( private val caseDefinitionProcessLinkService: CaseDefinitionProcessLinkService ) { + @EndpointDescription( + en = "Get case definition feature process", + nl = "Functieproces van dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/feature-process/{type}") fun getDocumentDefinitionProcess( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -52,6 +57,10 @@ class CaseDefinitionProcessManagementResource( return ResponseEntity.ok(result) } + @EndpointDescription( + en = "Save case definition feature process", + nl = "Functieproces van dossierdefinitie bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/feature-process") fun putDocumentDefinitionProcess( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @@ -64,6 +73,10 @@ class CaseDefinitionProcessManagementResource( return ResponseEntity.ok(response) } + @EndpointDescription( + en = "Delete case definition feature process", + nl = "Functieproces van dossierdefinitie verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/feature-process/{type}") fun deleteDocumentDefinitionProcess( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/ProcessCaseManagementResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/ProcessCaseManagementResource.kt index 48b9390921..603d542e62 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/ProcessCaseManagementResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/ProcessCaseManagementResource.kt @@ -19,6 +19,7 @@ import com.ritense.processdocument.domain.UpdateProcessDefinitionCaseDefinitionR import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping @@ -32,6 +33,10 @@ import org.springframework.web.bind.annotation.RestController class ProcessCaseManagementResource( private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService ) { + @EndpointDescription( + en = "Update process definition case definition properties", + nl = "Eigenschappen van procesdefinitie bij dossierdefinitie bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/process/{processDefinitionId}/properties") fun updateProcessDefinitionCaseDefinition( @PathVariable caseDefinitionKey: String, diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/TaskListResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/TaskListResource.kt index 22b179069e..de64974e5e 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/TaskListResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/TaskListResource.kt @@ -25,6 +25,7 @@ import com.ritense.processdocument.web.result.TaskListRowDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.authorization.UserManagementServiceHolder import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.service.OperatonTaskService import jakarta.validation.Valid import org.springframework.data.domain.Page @@ -48,6 +49,10 @@ class TaskListResource ( private val taskQuickSearchService: TaskQuickSearchService, ) { + @EndpointDescription( + en = "List filtered tasks", + nl = "Gefilterde takenlijst ophalen", + ) @PostMapping("/v3/task") fun getTaskList( @RequestParam("filter") assignmentFilter: OperatonTaskService.TaskFilter, @@ -61,6 +66,10 @@ class TaskListResource ( } } + @EndpointDescription( + en = "Search task list by case definition", + nl = "Takenlijst zoeken per dossierdefinitie", + ) @PostMapping("/v1/document-definition/{caseDefinitionName}/task/search") fun searchTaskList( @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -71,6 +80,10 @@ class TaskListResource ( return ResponseEntity.ok(result) } + @EndpointDescription( + en = "Save task quick search", + nl = "Snelzoekopdracht voor taken aanmaken", + ) @PostMapping("/v1/task/{caseDefinitionKey}/stored-quick-search") fun saveQuickSearch( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -81,6 +94,10 @@ class TaskListResource ( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete task quick search", + nl = "Snelzoekopdracht voor taken verwijderen", + ) @DeleteMapping("/v1/task/{caseDefinitionKey}/stored-quick-search/{title}") fun deleteQuickSearch( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -91,6 +108,10 @@ class TaskListResource ( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "List task quick searches", + nl = "Snelzoekopdrachten voor taken ophalen", + ) @GetMapping("/v1/task/{caseDefinitionKey}/stored-quick-search") fun getQuickSearchList( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/LogInspectionResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/LogInspectionResource.kt index 0df696fec1..c11144a2fc 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/LogInspectionResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/LogInspectionResource.kt @@ -38,6 +38,7 @@ import com.ritense.logging.web.rest.dto.LoggingEventResponse import com.ritense.processdocument.web.rest.dto.LogInspectionSearchRequest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable @@ -64,6 +65,10 @@ class LogInspectionResource( private val scopeContributors: List, ) { + @EndpointDescription( + en = "Search case logs", + nl = "Dossierlogregels zoeken", + ) @Transactional(readOnly = true) @PostMapping("/v1/case/{caseId}/logs") fun searchCaseLogs( diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessInspectionResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessInspectionResource.kt index a9609f31f3..2103d511f8 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessInspectionResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessInspectionResource.kt @@ -36,6 +36,7 @@ import com.ritense.processdocument.web.rest.dto.TaskInspectionDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.audit.utils.AuditHelper import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.utils.RequestHelper import com.ritense.valtimo.operaton.repository.OperatonTaskSpecificationHelper.Companion.byProcessInstanceId import com.ritense.valtimo.service.OperatonTaskService @@ -79,6 +80,10 @@ class ProcessInspectionResource( private val objectMapper: ObjectMapper, ) { + @EndpointDescription( + en = "Get case process inspection", + nl = "Procesinspectie van dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/processes") fun getProcessInspection( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID @@ -98,6 +103,10 @@ class ProcessInspectionResource( return ResponseEntity.ok(rows) } + @EndpointDescription( + en = "Create process instance variable", + nl = "Procesvariabele aanmaken", + ) @PostMapping("/v1/case/{caseId}/process-instance/{processInstanceId}/variables") fun createVariable( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, @@ -131,6 +140,10 @@ class ProcessInspectionResource( return ResponseEntity.status(HttpStatus.CREATED).build() } + @EndpointDescription( + en = "Update process instance variable", + nl = "Procesvariabele bijwerken", + ) @PutMapping("/v1/case/{caseId}/process-instance/{processInstanceId}/variables/{name}") fun updateVariable( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, @@ -163,6 +176,10 @@ class ProcessInspectionResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete process instance variable", + nl = "Procesvariabele verwijderen", + ) @DeleteMapping("/v1/case/{caseId}/process-instance/{processInstanceId}/variables/{name}") fun deleteVariable( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt index ee3923514d..936eadebcf 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt @@ -28,6 +28,7 @@ import com.ritense.processdocument.web.rest.dto.JobType import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.audit.utils.AuditHelper import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.utils.RequestHelper import com.ritense.valtimo.operaton.authorization.OperatonTimerActionProvider import com.ritense.valtimo.operaton.domain.OperatonTimer @@ -56,6 +57,10 @@ class ProcessTimerResource( ) { @Transactional(readOnly = true) + @EndpointDescription( + en = "List skippable timers of a process instance", + nl = "Overslaanbare timers van procesinstantie ophalen", + ) @GetMapping("/case/{caseId}/process-instance/{processInstanceId}/timers") fun getSkippableTimers( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, @@ -71,6 +76,10 @@ class ProcessTimerResource( } @Transactional + @EndpointDescription( + en = "Skip a timer of a process instance", + nl = "Timer van procesinstantie overslaan", + ) @PostMapping("/case/{caseId}/process-instance/{processInstanceId}/timer/{jobId}/skip") fun skipTimer( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, diff --git a/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/web/rest/URLProcessLinkResource.kt b/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/web/rest/URLProcessLinkResource.kt index a1193c8f39..394e8ea29c 100644 --- a/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/web/rest/URLProcessLinkResource.kt +++ b/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/web/rest/URLProcessLinkResource.kt @@ -21,6 +21,7 @@ import com.ritense.processlink.url.web.rest.dto.URLVariables import com.ritense.processlink.url.web.rest.dto.URLSubmissionResult import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -36,6 +37,10 @@ class URLProcessLinkResource( val urlProcessLinkService: URLProcessLinkService ) { + @EndpointDescription( + en = "Submit a URL process link", + nl = "Inzending voor een URL-proceskoppeling verwerken", + ) @PostMapping("/v1/process-link/url/{processLinkId}") fun handleSubmission( @PathVariable processLinkId: UUID, @@ -51,6 +56,10 @@ class URLProcessLinkResource( ) } + @EndpointDescription( + en = "Get the default process link URL", + nl = "Standaard-proceskoppeling-URL ophalen", + ) @GetMapping("/v1/process-link/url/variables") fun getDefaultUrl( ): URLVariables { diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt index 5f20a823dc..80cb704e55 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt @@ -77,29 +77,18 @@ open class ProcessLinkImporter( node.set("processDefinitionId", TextNode.valueOf(processDefinitionId)) } + val processLinkType = node.path("processLinkType").asText(null) + ?: throw IllegalStateException( + "Error while processing file ${request.fileName}. Item at index $index has no 'processLinkType'!" + ) + val mapper = processLinkService.getProcessLinkMapper(processLinkType) + val mappings = request.pluginConfigurationMappings - if (mappings != null && node.has("pluginConfigurationId")) { - val originalIdText = node.get("pluginConfigurationId").asText(null) - if (originalIdText != null) { - val originalId = try { - java.util.UUID.fromString(originalIdText) - } catch (_: IllegalArgumentException) { - null - } - if (originalId != null && mappings.containsKey(originalId)) { - val mappedId = mappings[originalId] - if (mappedId != null) { - node.set("pluginConfigurationId", TextNode.valueOf(mappedId.toString())) - } else { - node.putNull("pluginConfigurationId") - } - } - } + if (mappings != null) { + mapper.applyPluginConfigurationMappings(node, mappings) } val deployDto = objectMapper.treeToValue(node) - - val mapper = processLinkService.getProcessLinkMapper(deployDto.processLinkType) val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, request.caseDefinitionId) try { diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt index fc38f3635a..cc02641220 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt @@ -16,6 +16,7 @@ package com.ritense.processlink.mapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.exporter.manifest.ArtifactDependency import com.ritense.exporter.request.ExportRequest import com.ritense.processlink.autodeployment.ProcessLinkDeployDto @@ -77,4 +78,54 @@ interface ProcessLinkMapper { processDefinitionIds: Set, applicationEventPublisher: ApplicationEventPublisher ) { /* no-op default */ } + + /** + * Called by the process-link importer for every deployment node this mapper is responsible for, + * before the node is deserialized into a [ProcessLinkDeployDto]. Lets each mapper rewrite the + * plugin-configuration-id field(s) it owns using the imported-to-target-environment mapping + * (source configuration UUID -> target configuration UUID, or `null` when left dangling). + * Default is a no-op — mappers that don't reference a plugin configuration by id don't need this. + * + * @param node the mutable deployment JSON node about to be deserialized + * @param mappings source plugin-configuration UUID -> target plugin-configuration UUID (`null` + * value means "leave dangling", e.g. because the admin chose not to map it during import) + */ + fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { /* no-op default */ } +} + +/** + * Rewrites a single UUID-valued text field on [node] using [mappings] (source UUID -> target UUID, + * `null` meaning "leave dangling"). No-op when the field is absent, blank, not a valid UUID, or has + * no entry in [mappings]. Shared by [ProcessLinkMapper] implementations backing + * [ProcessLinkMapper.applyPluginConfigurationMappings]. + * + * [allowNull] controls what happens when [mappings] resolves the original id to `null`: when `true` + * (default) the field is nulled out on the node, leaving the id dangling for [ProcessLinkMapper]s + * whose deploy DTO declares the field nullable. When `false` — for DTOs whose field is non-nullable + * (e.g. `ExternalPluginTaskFormProcessLinkDeployDto.externalPluginConfigurationId`, where the + * reference is always `FIXED` and a `null` configuration id has no meaning) — the field is left + * unchanged rather than nulled out, since nulling it would fail deserialization; the id then simply + * stays dangling against its original (unmapped) value. + */ +fun remapConfigurationIdField( + node: ObjectNode, + fieldName: String, + mappings: Map, + allowNull: Boolean = true, +) { + if (!node.has(fieldName)) return + val originalIdText = node.get(fieldName).asText(null) ?: return + val originalId = try { + UUID.fromString(originalIdText) + } catch (_: IllegalArgumentException) { + return + } + if (!mappings.containsKey(originalId)) return + + val mappedId = mappings[originalId] + if (mappedId != null) { + node.put(fieldName, mappedId.toString()) + } else if (allowNull) { + node.putNull(fieldName) + } } diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt index 08b2413bd6..5996ae4faf 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkResource.kt @@ -40,6 +40,7 @@ import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.service.OperatonProcessService import com.ritense.valtimo.service.ProcessPropertyService import com.ritense.valtimo.web.rest.dto.ProcessDefinitionWithPropertiesDto @@ -80,6 +81,10 @@ class ProcessLinkResource( private val processPropertyService: ProcessPropertyService ) { + @EndpointDescription( + en = "List process links", + nl = "Proceskoppelingen ophalen", + ) @GetMapping("/v1/process-link") fun getProcessLinks( @LoggableResource(resourceType = OperatonProcessDefinition::class) @RequestParam("processDefinitionId") processDefinitionId: String, @@ -94,6 +99,10 @@ class ProcessLinkResource( return ResponseEntity.ok(list) } + @EndpointDescription( + en = "List supported process link types", + nl = "Ondersteunde proceskoppelingstypen ophalen", + ) @GetMapping("/v1/process-link/types") fun getSupportedProcessLinkTypes( @RequestParam(name = "activityType") activityType: String @@ -101,6 +110,10 @@ class ProcessLinkResource( return ResponseEntity.ok(processLinkService.getSupportedProcessLinkTypes(activityType)) } + @EndpointDescription( + en = "Create process link", + nl = "Proceskoppeling aanmaken", + ) @PostMapping("/v1/process-link") fun createProcessLink( @Valid @RequestBody processLink: ProcessLinkCreateRequestDto @@ -112,6 +125,10 @@ class ProcessLinkResource( } } + @EndpointDescription( + en = "Update process link", + nl = "Proceskoppeling bijwerken", + ) @PutMapping("/v1/process-link") fun updateProcessLink( @Valid @RequestBody processLink: ProcessLinkUpdateRequestDto @@ -122,6 +139,10 @@ class ProcessLinkResource( } } + @EndpointDescription( + en = "Delete process link", + nl = "Proceskoppeling verwijderen", + ) @DeleteMapping("/v1/process-link/{processLinkId}") fun deleteProcessLink( @LoggableResource(resourceType = ProcessLink::class) @PathVariable(name = "processLinkId") processLinkId: UUID @@ -133,6 +154,10 @@ class ProcessLinkResource( @Deprecated("Since 12.7.0") + @EndpointDescription( + en = "Export process links", + nl = "Proceskoppelingen exporteren", + ) @GetMapping("/v1/process-link/export") fun exportProcessLinks( @LoggableResource("processDefinitionKey") @RequestParam("processDefinitionKey") processDefinitionKey: String @@ -145,6 +170,10 @@ class ProcessLinkResource( return ResponseEntity.ok(list) } + @EndpointDescription( + en = "List process definitions with links by case definition", + nl = "Procesdefinities met koppelingen per dossierdefinitie ophalen", + ) @GetMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/process-definition"], ) @@ -178,6 +207,10 @@ class ProcessLinkResource( return ResponseEntity.ok(definitions) } + @EndpointDescription( + en = "List unlinked process definitions with links", + nl = "Ongekoppelde procesdefinities met koppelingen ophalen", + ) @GetMapping("/management/v1/process-definition") @Transactional fun getUnlinkedProcessDefinitionsAndProcessLinks(): ResponseEntity> { @@ -203,6 +236,10 @@ class ProcessLinkResource( return ResponseEntity.ok(definitions) } + @EndpointDescription( + en = "List unlinked process definitions by key", + nl = "Ongekoppelde procesdefinities per sleutel ophalen", + ) @GetMapping("/management/v1/process-definition/key/{processDefinitionKey}") @Transactional fun getUnlinkedProcessDefinitionsByKeyList( @@ -229,6 +266,10 @@ class ProcessLinkResource( } + @EndpointDescription( + en = "Get single process definition with links", + nl = "Enkele procesdefinitie met koppelingen ophalen", + ) @GetMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/process-definition/{processDefinitionId}"], ) @@ -254,6 +295,10 @@ class ProcessLinkResource( return ResponseEntity.ok(responseDto) } + @EndpointDescription( + en = "List global process definitions with links by key", + nl = "Globale procesdefinities met koppelingen per sleutel ophalen", + ) @GetMapping("/management/v1/process-definition/{processDefinitionKey}") @Transactional fun getUnlinkedProcessDefinitionsWithLinks( @@ -274,6 +319,10 @@ class ProcessLinkResource( return ResponseEntity.ok(responseDto) } + @EndpointDescription( + en = "Get process definition by key with links", + nl = "Procesdefinitie per sleutel met koppelingen ophalen", + ) @GetMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/process-definition/key/{processDefinitionKey}"] ) @@ -306,6 +355,10 @@ class ProcessLinkResource( return ResponseEntity.ok(responseDto) } + @EndpointDescription( + en = "Delete process definitions and links by key", + nl = "Procesdefinities en koppelingen per sleutel verwijderen", + ) @DeleteMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/process-definition/key/{processDefinitionKey}"], ) @@ -333,6 +386,10 @@ class ProcessLinkResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Delete unlinked process definitions and links by key", + nl = "Ongekoppelde procesdefinities en koppelingen per sleutel verwijderen", + ) @DeleteMapping("/management/v1/process-definition/key/{processDefinitionKey}") @Transactional fun deleteUnlinkedProcessDefinitionsAndLinksByKey( @@ -349,6 +406,10 @@ class ProcessLinkResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Deploy process definition and links for case definition", + nl = "Procesdefinitie en koppelingen voor dossierdefinitie uitrollen", + ) @PostMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/process-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -383,6 +444,10 @@ class ProcessLinkResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Update process definition and links for case definition", + nl = "Procesdefinitie en koppelingen voor dossierdefinitie bijwerken", + ) @PutMapping( value = ["/management/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/process-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -411,6 +476,10 @@ class ProcessLinkResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Deploy unlinked process definition and links", + nl = "Ongekoppelde procesdefinitie en koppelingen uitrollen", + ) @PostMapping( value = ["/management/v1/process-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -438,6 +507,10 @@ class ProcessLinkResource( return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } + @EndpointDescription( + en = "Update unlinked process definition and links", + nl = "Ongekoppelde procesdefinitie en koppelingen bijwerken", + ) @PutMapping( value = ["/management/v1/process-definition"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], @@ -460,6 +533,10 @@ class ProcessLinkResource( } + @EndpointDescription( + en = "Validate a BPMN process definition and its process links", + nl = "Een BPMN-procesdefinitie en de bijbehorende proceskoppelingen valideren", + ) @PostMapping( value = ["/management/v1/process-definition/validate"], consumes = [MediaType.APPLICATION_JSON_VALUE], diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkTaskResource.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkTaskResource.kt index 1b19862bef..003edbeeed 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkTaskResource.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/web/rest/ProcessLinkTaskResource.kt @@ -23,6 +23,7 @@ import com.ritense.processlink.web.rest.dto.ProcessLinkActivityResult import com.ritense.processlink.web.rest.dto.ProcessLinkActivityResultWithTask import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.utils.SecurityUtils import com.ritense.valtimo.operaton.domain.OperatonTask import com.ritense.valtimo.task.service.UserTaskOpenedStatusService @@ -41,6 +42,10 @@ class ProcessLinkTaskResource( private var processLinkActivityService: ProcessLinkActivityService, private val userTaskOpenedStatusService: UserTaskOpenedStatusService ) { + @EndpointDescription( + en = "Open task process link", + nl = "Proceskoppeling van taak openen", + ) @GetMapping(value = ["/v2/process-link/task/{taskId}"]) fun getTask( @LoggableResource(resourceType = OperatonTask::class) @PathVariable taskId: UUID @@ -56,6 +61,10 @@ class ProcessLinkTaskResource( } } + @EndpointDescription( + en = "Get process start form", + nl = "Startformulier van proces ophalen", + ) @GetMapping(value = ["/v1/process-definition/{processDefinitionId}/start-form"]) fun getFormDefinition( @PathVariable processDefinitionId: String, @@ -71,6 +80,10 @@ class ProcessLinkTaskResource( ) } + @EndpointDescription( + en = "List process tasks with process links", + nl = "Procestaken met proceskoppelingen ophalen", + ) @GetMapping("/v1/process/{processInstanceId}/tasks/process-link") fun getTasksWithProcessLinks( @PathVariable processInstanceId: String diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt index 4b9f4a3d8b..b2bfaeaba3 100644 --- a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt @@ -17,6 +17,7 @@ package com.ritense.processlink.importer import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.PROCESS_DEFINITION import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService @@ -24,6 +25,7 @@ import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.service.ProcessLinkService import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto @@ -247,6 +249,10 @@ class ProcessLinkImporterTest { ): ProcessLink { throw UnsupportedOperationException() } + + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "pluginConfigurationId", mappings) + } } private companion object { diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt new file mode 100644 index 0000000000..f6fa0595c2 --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processlink.mapper + +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class RemapConfigurationIdFieldTest { + + private val objectMapper = ObjectMapper() + + @Test + fun `rewrites the field to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to targetId)) + + assertThat(node.get("configId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `nulls the field when the mapping value is null and allowNull is true`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to null)) + + assertThat(node.get("configId").isNull).isTrue() + } + + @Test + fun `leaves the field unchanged when the mapping value is null and allowNull is false`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to null), allowNull = false) + + assertThat(node.get("configId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `leaves the field unchanged when there is no mapping entry for the original id`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.get("configId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `is a no-op when the field is absent`() { + val node = objectMapper.createObjectNode() + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.has("configId")).isFalse() + } + + @Test + fun `is a no-op when the field is not a valid UUID`() { + val node = objectMapper.createObjectNode().put("configId", "not-a-uuid") + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.get("configId").asText()).isEqualTo("not-a-uuid") + } +} diff --git a/backend/resource/src/main/kotlin/com/ritense/resource/web/rest/ResourceResource.kt b/backend/resource/src/main/kotlin/com/ritense/resource/web/rest/ResourceResource.kt index 8cc146775f..0e549ba2ca 100644 --- a/backend/resource/src/main/kotlin/com/ritense/resource/web/rest/ResourceResource.kt +++ b/backend/resource/src/main/kotlin/com/ritense/resource/web/rest/ResourceResource.kt @@ -20,6 +20,7 @@ import com.ritense.resource.web.ObjectUrlDTO import com.ritense.resource.web.ResourceDTO import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -35,15 +36,31 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api", produces = [APPLICATION_JSON_UTF8_VALUE]) interface ResourceResource { + @EndpointDescription( + en = "Get resource by id", + nl = "Bestand ophalen op id", + ) @GetMapping("/v1/resource/{resourceId}") fun get(@PathVariable(name = "resourceId") resourceId: String): ResponseEntity + @EndpointDescription( + en = "Download resource content", + nl = "Bestandsinhoud downloaden", + ) @GetMapping("/v1/resource/{resourceId}/download") fun getContent(@PathVariable(name = "resourceId") resourceId: String): ResponseEntity + @EndpointDescription( + en = "Register resource", + nl = "Bestand registreren", + ) @PutMapping("/v1/resource", consumes = [APPLICATION_JSON_UTF8_VALUE]) fun register(@Valid @RequestBody resourceDTO: ResourceDTO): ResponseEntity + @EndpointDescription( + en = "Delete resource by id", + nl = "Bestand verwijderen op id", + ) @DeleteMapping("/v1/resource/{resourceId}") fun delete(@PathVariable(name = "resourceId") resourceId: String): ResponseEntity diff --git a/backend/resource/temporary-resource-storage/src/main/kotlin/com/ritense/resource/web/rest/TemporaryResourceStorageResource.kt b/backend/resource/temporary-resource-storage/src/main/kotlin/com/ritense/resource/web/rest/TemporaryResourceStorageResource.kt index b023c4c996..9066d57dae 100644 --- a/backend/resource/temporary-resource-storage/src/main/kotlin/com/ritense/resource/web/rest/TemporaryResourceStorageResource.kt +++ b/backend/resource/temporary-resource-storage/src/main/kotlin/com/ritense/resource/web/rest/TemporaryResourceStorageResource.kt @@ -23,6 +23,7 @@ import com.ritense.resource.web.rest.response.ResourceDto import com.ritense.resource.web.rest.response.StorageMetadataValue import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.contract.utils.SecurityUtils import org.springframework.context.ApplicationEventPublisher import org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE @@ -43,6 +44,10 @@ class TemporaryResourceStorageResource( private val applicationEventPublisher: ApplicationEventPublisher, ) { + @EndpointDescription( + en = "Upload temporary resource with metadata", + nl = "Tijdelijk bestand uploaden met metadata", + ) @PostMapping("/v1/resource/temp", consumes = [MULTIPART_FORM_DATA_VALUE]) fun uploadFileWithMetadata( @RequestParam("file") file: MultipartFile, @@ -66,6 +71,10 @@ class TemporaryResourceStorageResource( ) } + @EndpointDescription( + en = "Get temporary resource metadata value", + nl = "Metadata-waarde van tijdelijk bestand ophalen", + ) @GetMapping("/v1/resource-storage/{resourceStorageFieldId}/metadata/{metadataKey}") fun getMetadataValue( @PathVariable("resourceStorageFieldId") resourceStorageFieldId: String, diff --git a/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchFieldV2Resource.kt b/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchFieldV2Resource.kt index eac180c048..2fc3005f89 100644 --- a/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchFieldV2Resource.kt +++ b/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchFieldV2Resource.kt @@ -20,6 +20,7 @@ import com.ritense.search.service.SearchFieldV2Service import com.ritense.search.web.rest.dto.SearchFieldV2Dto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -38,6 +39,10 @@ class SearchFieldV2Resource( private val searchFieldV2Service: SearchFieldV2Service ) { + @EndpointDescription( + en = "Create search field", + nl = "Zoekveld aanmaken", + ) @PostMapping("/{ownerId}") fun create( @PathVariable ownerId: String, @@ -45,6 +50,10 @@ class SearchFieldV2Resource( ) = ResponseEntity.ok(searchFieldV2Service.create(searchFieldV2Dto)) + @EndpointDescription( + en = "Update search field by key", + nl = "Zoekveld bijwerken op sleutel", + ) @PutMapping("/{ownerId}/{key}") fun update( @PathVariable ownerId: String, @@ -53,6 +62,10 @@ class SearchFieldV2Resource( ) = ResponseEntity.ok(searchFieldV2Service.update(searchFieldV2Dto)) + @EndpointDescription( + en = "Update search field list", + nl = "Lijst met zoekvelden bijwerken", + ) @PutMapping("/{ownerId}/fields") fun updateList( @PathVariable ownerId: String, @@ -60,15 +73,27 @@ class SearchFieldV2Resource( ) = ResponseEntity.ok(searchFieldV2Service.updateList(ownerId, searchFieldV2Dtos)) + @EndpointDescription( + en = "List search fields by owner", + nl = "Zoekvelden ophalen per eigenaar", + ) @Deprecated("Since 12.1.0") @GetMapping("/{ownerId}") fun getAllByOwnerId(@PathVariable ownerId: String) = ResponseEntity.ok(searchFieldV2Service.findAllByOwnerId(ownerId)) + @EndpointDescription( + en = "List search fields by owner type and id", + nl = "Zoekvelden ophalen per eigenaartype en id", + ) @GetMapping("/{ownerType}/{ownerId}") fun getAllByOwnerTypeAndOwnerId(@PathVariable ownerType: String, @PathVariable ownerId: String) = ResponseEntity.ok(searchFieldV2Service.findAllByOwnerTypeAndOwnerId(ownerType, ownerId)) + @EndpointDescription( + en = "Delete search field by key", + nl = "Zoekveld verwijderen op sleutel", + ) @Deprecated("Since 12.1.0") @DeleteMapping("/{ownerId}/{key}") fun delete( @@ -79,6 +104,10 @@ class SearchFieldV2Resource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Delete search field by owner type and key", + nl = "Zoekveld verwijderen op eigenaartype en sleutel", + ) @DeleteMapping("/{ownerType}/{ownerId}/{key}") fun delete( @PathVariable ownerType: String, diff --git a/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchListColumnResource.kt b/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchListColumnResource.kt index 8c1311cb1b..de94a58e2a 100644 --- a/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchListColumnResource.kt +++ b/backend/search/src/main/kotlin/com/ritense/search/web/rest/SearchListColumnResource.kt @@ -20,6 +20,7 @@ import com.ritense.search.domain.SearchListColumn import com.ritense.search.service.SearchListColumnService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -38,6 +39,10 @@ class SearchListColumnResource( private val searchListColumnService: SearchListColumnService ) { + @EndpointDescription( + en = "Create list column", + nl = "Lijstkolom aanmaken", + ) @PostMapping("/{ownerId}") fun create( @PathVariable ownerId: String, @@ -45,6 +50,10 @@ class SearchListColumnResource( ) = ResponseEntity.ok(searchListColumnService.create(searchListColumn)) + @EndpointDescription( + en = "Update list column by key", + nl = "Lijstkolom bijwerken op sleutel", + ) @PutMapping("/{ownerId}/{key}") fun update( @PathVariable ownerId: String, @@ -53,6 +62,10 @@ class SearchListColumnResource( ) = ResponseEntity.ok(searchListColumnService.update(searchListColumn)) + @EndpointDescription( + en = "Update list column list", + nl = "Lijst met lijstkolommen bijwerken", + ) @PutMapping("/{ownerId}/search-list-columns") fun updateList( @PathVariable ownerId: String, @@ -60,10 +73,18 @@ class SearchListColumnResource( ) = ResponseEntity.ok(searchListColumnService.updateList(searchListColumn)) + @EndpointDescription( + en = "List columns by owner", + nl = "Lijstkolommen ophalen per eigenaar", + ) @GetMapping("/{ownerId}") fun getByKey(@PathVariable ownerId: String) = ResponseEntity.ok(searchListColumnService.findByOwnerId(ownerId)) + @EndpointDescription( + en = "Delete list column by key", + nl = "Lijstkolom verwijderen op sleutel", + ) @DeleteMapping("/{ownerId}/{key}") fun delete( @PathVariable ownerId: String, diff --git a/backend/team/src/main/kotlin/com/ritense/team/web/rest/TeamResource.kt b/backend/team/src/main/kotlin/com/ritense/team/web/rest/TeamResource.kt index d68116cadc..90c18620ff 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/web/rest/TeamResource.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/web/rest/TeamResource.kt @@ -28,6 +28,7 @@ import jakarta.validation.Valid import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.authentication.ManageableUser import com.ritense.valtimo.contract.authentication.UserManagementService +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.web.SortDefault @@ -52,6 +53,10 @@ class TeamResource( private val userManagementService: UserManagementService, ) { + @EndpointDescription( + en = "List all teams", + nl = "Alle teams ophalen", + ) @GetMapping fun getAllTeams( @RequestParam(required = false) titleContains: String?, @@ -60,12 +65,20 @@ class TeamResource( return teamManagementService.findAll(titleContains, pageable).map { TeamListResponseDto.from(it) } } + @EndpointDescription( + en = "Get a team by key", + nl = "Team op sleutel ophalen", + ) @GetMapping("/{key}") fun getTeamById(@PathVariable key: String): TeamResponseDto { val team = teamManagementService.findByKey(key) ?: error("Team not found") return TeamResponseDto.from(team) } + @EndpointDescription( + en = "Create a team", + nl = "Team aanmaken", + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createTeam(@Valid @RequestBody request: TeamCreateRequestDto): TeamResponseDto { @@ -73,18 +86,30 @@ class TeamResource( return TeamResponseDto.from(team) } + @EndpointDescription( + en = "Update a team", + nl = "Team bijwerken", + ) @PutMapping("/{key}") fun updateTeam(@PathVariable key: String, @Valid @RequestBody request: TeamUpdateRequestDto): TeamResponseDto { val team = teamManagementService.update(key, request.title) return TeamResponseDto.from(team) } + @EndpointDescription( + en = "Delete a team", + nl = "Team verwijderen", + ) @DeleteMapping("/{key}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteTeam(@PathVariable key: String) { teamManagementService.delete(key) } + @EndpointDescription( + en = "List users in a team", + nl = "Gebruikers van een team ophalen", + ) @GetMapping("/{teamKey}/user") fun getTeamUsers( @PathVariable teamKey: String, @@ -95,6 +120,10 @@ class TeamResource( .map { uname -> TeamUserResponseDto.from(userManagementService.findByUsername(uname)) } } + @EndpointDescription( + en = "Add a user to a team", + nl = "Gebruiker aan een team toevoegen", + ) @PostMapping("/{teamKey}/user") @ResponseStatus(HttpStatus.CREATED) fun addUserToTeam( @@ -105,6 +134,10 @@ class TeamResource( return TeamUserResponseDto.from(userManagementService.findByUsername(username)) } + @EndpointDescription( + en = "Remove a user from a team", + nl = "Gebruiker uit een team verwijderen", + ) @DeleteMapping("/{teamKey}/user/{username}") @ResponseStatus(HttpStatus.NO_CONTENT) fun removeUserFromTeam( @@ -114,6 +147,10 @@ class TeamResource( teamManagementService.removeUserFromTeam(username, teamKey) } + @EndpointDescription( + en = "List candidate users for a team", + nl = "Kandidaat-gebruikers voor een team ophalen", + ) @GetMapping("/{teamKey}/candidate-user") fun getCandidateUsers(@PathVariable teamKey: String): List { val memberUsernames = teamManagementService.findAllTeamUsernames(teamKey = teamKey).content.toSet() diff --git a/backend/value-resolver/src/main/kotlin/com/ritense/valueresolver/web/rest/ValueResolverResource.kt b/backend/value-resolver/src/main/kotlin/com/ritense/valueresolver/web/rest/ValueResolverResource.kt index 080f1273a2..bb3a07a0e1 100644 --- a/backend/value-resolver/src/main/kotlin/com/ritense/valueresolver/web/rest/ValueResolverResource.kt +++ b/backend/value-resolver/src/main/kotlin/com/ritense/valueresolver/web/rest/ValueResolverResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -19,9 +19,9 @@ package com.ritense.valueresolver.web.rest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valueresolver.ValueResolverOption import com.ritense.valueresolver.ValueResolverOptionRequest -import com.ritense.valueresolver.ValueResolverOptionType import com.ritense.valueresolver.ValueResolverService import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -38,11 +38,19 @@ import org.springframework.web.bind.annotation.RestController class ValueResolverResource( private val valueResolverService: ValueResolverService ) { + @EndpointDescription( + en = "List value resolvers", + nl = "Waarde-resolvers ophalen", + ) @GetMapping("/management/v1/value-resolver") fun getValueResolvers(): ResponseEntity> { return ResponseEntity.ok(valueResolverService.getValueResolvers()) } + @EndpointDescription( + en = "Get resolvable keys for case definition", + nl = "Opvraagbare sleutels voor dossierdefinitie ophalen", + ) @PostMapping("/management/v1/value-resolver/case-definition/{caseDefinitionKey}/keys") fun getResolvableKeys( @PathVariable caseDefinitionKey: String, @@ -51,6 +59,10 @@ class ValueResolverResource( return ResponseEntity.ok(valueResolverService.getResolvableKeys(request, caseDefinitionKey)) } + @EndpointDescription( + en = "Get resolvable keys for case definition version", + nl = "Opvraagbare sleutels voor dossierdefinitieversie ophalen", + ) @PostMapping("/management/v1/value-resolver/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/keys") fun getResolvableKeys( @PathVariable caseDefinitionKey: String, diff --git a/backend/web/src/main/java/com/ritense/valtimo/web/autoconfigure/OpenApiAutoConfiguration.java b/backend/web/src/main/java/com/ritense/valtimo/web/autoconfigure/OpenApiAutoConfiguration.java index 40f0fb5044..fcfc5a7296 100644 --- a/backend/web/src/main/java/com/ritense/valtimo/web/autoconfigure/OpenApiAutoConfiguration.java +++ b/backend/web/src/main/java/com/ritense/valtimo/web/autoconfigure/OpenApiAutoConfiguration.java @@ -16,17 +16,21 @@ package com.ritense.valtimo.web.autoconfigure; +import com.ritense.valtimo.contract.endpoint.EndpointDescription; import com.ritense.valtimo.web.config.OpenApiProperties; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springdoc.core.customizers.OperationCustomizer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.web.method.HandlerMethod; /** * OpenAPI configuration. @@ -70,4 +74,25 @@ public OpenAPI valtimoOpenAPI(OpenApiProperties openApiProperties) { return openAPI; } + /** + * Surfaces the {@link EndpointDescription} declared on each controller handler method in the + * generated OpenAPI document. springdoc invokes this customizer for every operation while it + * builds the document at runtime, so the annotation stays the single source of truth: its + * English text becomes the operation summary (the short label Swagger shows for each endpoint). + * An explicit {@code @Operation} summary always takes precedence. + * + * @return the operation customizer that copies endpoint descriptions into the OpenAPI document + */ + @Bean + @ConditionalOnMissingBean(name = "endpointDescriptionOperationCustomizer") + public OperationCustomizer endpointDescriptionOperationCustomizer() { + return (Operation operation, HandlerMethod handlerMethod) -> { + EndpointDescription description = handlerMethod.getMethodAnnotation(EndpointDescription.class); + if (description != null && (operation.getSummary() == null || operation.getSummary().isBlank())) { + operation.setSummary(description.en()); + } + return operation; + }; + } + } diff --git a/backend/web/src/main/kotlin/com/ritense/valtimo/web/sse/web/rest/SseResource.kt b/backend/web/src/main/kotlin/com/ritense/valtimo/web/sse/web/rest/SseResource.kt index 70b4e3c1c5..0ea2cfedf1 100644 --- a/backend/web/src/main/kotlin/com/ritense/valtimo/web/sse/web/rest/SseResource.kt +++ b/backend/web/src/main/kotlin/com/ritense/valtimo/web/sse/web/rest/SseResource.kt @@ -16,6 +16,7 @@ package com.ritense.valtimo.web.sse.web.rest +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.valtimo.web.sse.domain.Subscriber import com.ritense.valtimo.web.sse.service.SseSubscriptionService import io.github.oshai.kotlinlogging.KotlinLogging @@ -29,9 +30,17 @@ class SseResource( private val sseSubscriptionService: SseSubscriptionService ) { + @EndpointDescription( + en = "Subscribe to server-sent events", + nl = "Inschrijven op server-sent events", + ) @GetMapping("/api/v1/sse") fun subscribeToEvents() = sseSubscriptionService.subscribe() + @EndpointDescription( + en = "Subscribe to server-sent events by subscription id", + nl = "Inschrijven op server-sent events op inschrijvings-id", + ) @GetMapping("/api/v1/sse/{subscriptionId}") fun subscribeToEvents( @PathVariable subscriptionId: UUID? diff --git a/backend/web/src/test/kotlin/com/ritense/valtimo/web/autoconfigure/EndpointDescriptionOperationCustomizerTest.kt b/backend/web/src/test/kotlin/com/ritense/valtimo/web/autoconfigure/EndpointDescriptionOperationCustomizerTest.kt new file mode 100644 index 0000000000..9e285dad1d --- /dev/null +++ b/backend/web/src/test/kotlin/com/ritense/valtimo/web/autoconfigure/EndpointDescriptionOperationCustomizerTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimo.web.autoconfigure + +import com.ritense.valtimo.contract.endpoint.EndpointDescription +import io.swagger.v3.oas.models.Operation +import org.junit.jupiter.api.Test +import org.springframework.web.method.HandlerMethod +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EndpointDescriptionOperationCustomizerTest { + + private val customizer = OpenApiAutoConfiguration().endpointDescriptionOperationCustomizer() + + @Test + fun `copies the English text to the operation summary`() { + val operation = Operation() + + customizer.customize(operation, handlerMethod("annotated")) + + assertEquals("List cases", operation.summary) + } + + @Test + fun `leaves the summary untouched when the annotation is absent`() { + val operation = Operation() + + customizer.customize(operation, handlerMethod("plain")) + + assertNull(operation.summary) + } + + @Test + fun `does not override an explicit summary`() { + val operation = Operation().summary("explicit summary") + + customizer.customize(operation, handlerMethod("annotated")) + + assertEquals("explicit summary", operation.summary) + } + + private fun handlerMethod(methodName: String): HandlerMethod = + HandlerMethod(TestController(), TestController::class.java.getDeclaredMethod(methodName)) + + class TestController { + @EndpointDescription(en = "List cases", nl = "Toon zaken") + fun annotated() { + } + + fun plain() { + } + } +} diff --git a/backend/zgw/catalogi-api/src/main/kotlin/com/ritense/catalogiapi/web/rest/CatalogiResource.kt b/backend/zgw/catalogi-api/src/main/kotlin/com/ritense/catalogiapi/web/rest/CatalogiResource.kt index 88b555432e..970adc83b4 100644 --- a/backend/zgw/catalogi-api/src/main/kotlin/com/ritense/catalogiapi/web/rest/CatalogiResource.kt +++ b/backend/zgw/catalogi-api/src/main/kotlin/com/ritense/catalogiapi/web/rest/CatalogiResource.kt @@ -33,6 +33,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.semver4j.Semver import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -51,6 +52,10 @@ class CatalogiResource( private val caseDefinitionService: CaseDefinitionService, private val documentService: DocumentService ) { + @EndpointDescription( + en = "List zaaktype informatieobjecttypes by case definition", + nl = "Informatieobjecttypen van zaaktype ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaaktype/documenttype") fun getZaakObjecttypes( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -68,6 +73,10 @@ class CatalogiResource( return ResponseEntity.ok(zaakObjectTypes) } + @EndpointDescription( + en = "List zaaktype informatieobjecttypes by document", + nl = "Informatieobjecttypen van zaaktype per document ophalen", + ) @GetMapping("/v1/document/{documentId}/zaaktype/documenttype") fun getZaakObjecttypes( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable documentId: UUID, @@ -85,6 +94,10 @@ class CatalogiResource( return ResponseEntity.ok(zaakObjectTypes) } + @EndpointDescription( + en = "List zaaktype role types", + nl = "Roltypen van zaaktype ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/zaaktype/roltype") fun getZaakRoltypes( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -104,6 +117,10 @@ class CatalogiResource( return ResponseEntity.ok(zaakRolTypes) } + @EndpointDescription( + en = "List zaaktype status types", + nl = "Statustypen van zaaktype ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaaktype/statustype") fun getZaakStatustypen( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -120,6 +137,10 @@ class CatalogiResource( return ResponseEntity.ok(zaakStatusTypes) } + @EndpointDescription( + en = "List zaaktype result types", + nl = "Resultaattypen van zaaktype ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaaktype/resultaattype") fun getZaakResultaattypen( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -137,6 +158,10 @@ class CatalogiResource( return ResponseEntity.ok(zaakResultaatTypes) } + @EndpointDescription( + en = "List zaaktype besluittypes", + nl = "Besluittypen van zaaktype ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaaktype/besluittype") fun getZaakBesuilttypen( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -153,12 +178,20 @@ class CatalogiResource( return ResponseEntity.ok(zaakBesluitTypes) } + @EndpointDescription( + en = "List zaaktypes", + nl = "Zaaktypen ophalen", + ) @GetMapping("/management/v1/zgw/zaaktype") fun getZaakTypen(): ResponseEntity> { val zaakTypen = catalogiService.getZaakTypen().map { ZaaktypeDto.of(it) } return ResponseEntity.ok(zaakTypen) } + @EndpointDescription( + en = "List catalogus eigenschappen", + nl = "Catalogus eigenschappen ophalen", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/catalogi-eigenschappen") fun getEigenschappen( @LoggableResource("caseDefinitionKey") @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/zgw/documenten-api-preview/src/main/kotlin/com/ritense/documentenapipreview/web/rest/DocumentenApiPreviewResource.kt b/backend/zgw/documenten-api-preview/src/main/kotlin/com/ritense/documentenapipreview/web/rest/DocumentenApiPreviewResource.kt index 4d1b35a3c3..dd7928f7ba 100644 --- a/backend/zgw/documenten-api-preview/src/main/kotlin/com/ritense/documentenapipreview/web/rest/DocumentenApiPreviewResource.kt +++ b/backend/zgw/documenten-api-preview/src/main/kotlin/com/ritense/documentenapipreview/web/rest/DocumentenApiPreviewResource.kt @@ -21,6 +21,7 @@ import com.ritense.logging.LoggableResource import com.ritense.plugin.domain.PluginConfiguration import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.core.io.InputStreamResource import org.springframework.http.ContentDisposition import org.springframework.http.HttpHeaders @@ -39,6 +40,10 @@ import java.util.UUID class DocumentenApiPreviewResource( private val documentenApiPreviewService: DocumentenApiPreviewService ) { + @EndpointDescription( + en = "Get a document preview", + nl = "Voorbeeld van een document ophalen", + ) @GetMapping("/v1/documenten-api-preview/{pluginConfigurationId}/preview/{caseDocumentId}/{documentId}") fun preview( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: String, @@ -59,6 +64,10 @@ class DocumentenApiPreviewResource( .body(InputStreamResource(pdfFile.content)) } + @EndpointDescription( + en = "Check if document preview is configured", + nl = "Controleren of het documentvoorbeeld is geconfigureerd", + ) @GetMapping("/v1/documenten-api-preview/configuration-exists/{documentenApiConfigurationId}") fun isPreviewConfigured( @PathVariable(name = "documentenApiConfigurationId") documentenApiConfigurationId: String, diff --git a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiManagementResource.kt b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiManagementResource.kt index 1dd60835bd..3f67f7ffc0 100644 --- a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiManagementResource.kt +++ b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiManagementResource.kt @@ -30,6 +30,7 @@ import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -48,6 +49,10 @@ class DocumentenApiManagementResource( private val documentenApiVersionService: DocumentenApiVersionService ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get document column keys for case definition", + nl = "Documentkolomsleutels voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column-key") fun getColumnKeys( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -59,6 +64,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get configured document columns for case definition", + nl = "Geconfigureerde documentkolommen voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column") fun getConfiguredColumns( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -70,6 +79,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update document column order", + nl = "Volgorde van documentkolommen bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column") fun updateColumnOrder( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -83,6 +96,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create or update document column", + nl = "Documentkolom aanmaken of bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column/{columnKey}") fun createOrUpdateColumn( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -95,6 +112,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete document column", + nl = "Documentkolom verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column/{columnKey}") fun deleteColumn( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -105,6 +126,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get Documenten API version for case definition", + nl = "Documenten API-versie voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/documenten-api/version") fun getApiVersion( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -115,6 +140,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get Documenten API version for case definition version", + nl = "Documenten API-versie voor dossierdefinitieversie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/version/{caseDefinitionVersionTag}/documenten-api/version") fun getApiVersionForVersion( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -127,6 +156,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get all Documenten API versions", + nl = "Alle Documenten API-versies ophalen", + ) @GetMapping("/v1/documenten-api/versions") fun getAllApiVersion(): ResponseEntity { val versions = documentenApiVersionService.getAllVersions() @@ -134,6 +167,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Get document upload fields for case definition", + nl = "Documentuploadvelden voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/zgw-document/upload-field") fun getUploadFields( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -143,6 +180,10 @@ class DocumentenApiManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Update document upload field", + nl = "Documentuploadveld bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionName}/zgw-document/upload-field") fun updateUploadField( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, diff --git a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiResource.kt b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiResource.kt index 9919ff739d..d219b79589 100644 --- a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiResource.kt +++ b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/DocumentenApiResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import com.ritense.logging.LoggableResource import com.ritense.plugin.domain.PluginConfiguration import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.core.io.InputStreamResource import org.springframework.http.HttpHeaders @@ -50,6 +51,10 @@ class DocumentenApiResource( private val documentenApiVersionService: DocumentenApiVersionService ) { @Deprecated("Will be removed in 14.0", ReplaceWith("ZaakDocumentResource.downloadDocument(pluginConfigurationId, caseDocumentId, documentId)")) + @EndpointDescription( + en = "Download document file", + nl = "Documentbestand downloaden", + ) @GetMapping("/v1/documenten-api/{pluginConfigurationId}/files/{documentId}/download") fun downloadDocument( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: String, @@ -76,6 +81,10 @@ class DocumentenApiResource( } @Deprecated("Will be removed in 14.0", ReplaceWith("ZaakDocumentResource.modifyDocument(pluginConfigurationId, caseDocumentId, documentId)")) + @EndpointDescription( + en = "Modify document metadata", + nl = "Documentmetadata bijwerken", + ) @PutMapping("/v1/documenten-api/{pluginConfigurationId}/files/{documentId}") fun modifyDocument( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: String, @@ -96,6 +105,10 @@ class DocumentenApiResource( } @Deprecated("Will be removed in 14.0", ReplaceWith("ZaakDocumentResource.deleteDocument(pluginConfigurationId, caseDocumentId, documentId)")) + @EndpointDescription( + en = "Delete document", + nl = "Document verwijderen", + ) @DeleteMapping("/v1/documenten-api/{pluginConfigurationId}/files/{documentId}") fun deleteDocument( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: String, @@ -108,6 +121,10 @@ class DocumentenApiResource( .build() } + @EndpointDescription( + en = "Get document columns for case definition", + nl = "Documentkolommen voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/zgw-document-column") fun getColumns( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -118,6 +135,10 @@ class DocumentenApiResource( return ResponseEntity.ok(columns) } + @EndpointDescription( + en = "Get Documenten API version for case definition", + nl = "Documenten API-versie voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/documenten-api/version") fun getApiVersion( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -126,6 +147,10 @@ class DocumentenApiResource( return ResponseEntity.ok(DocumentenApiVersionDto.of(version)) } + @EndpointDescription( + en = "Get resolved document upload fields", + nl = "Verwerkte documentuploadvelden ophalen", + ) @GetMapping("/v1/document/{documentId}/zgw-document/upload-field") fun getResolvedUploadFields( @LoggableResource("documentId") @PathVariable(name = "documentId") documentId: String diff --git a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/ZgwDocumentTrefwoordResource.kt b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/ZgwDocumentTrefwoordResource.kt index 1122cf834e..144788fd03 100644 --- a/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/ZgwDocumentTrefwoordResource.kt +++ b/backend/zgw/documenten-api/src/main/kotlin/com/ritense/documentenapi/web/rest/ZgwDocumentTrefwoordResource.kt @@ -21,6 +21,7 @@ import com.ritense.documentenapi.service.ZgwDocumentTrefwoordService import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.http.ResponseEntity @@ -40,6 +41,10 @@ class ZgwDocumentTrefwoordResource( val zgwDocumentTrefwoordService: ZgwDocumentTrefwoordService ) { + @EndpointDescription( + en = "Get document trefwoorden for case definition", + nl = "Documenttrefwoorden voor dossierdefinitie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionName}/zgw-document/trefwoord") fun getTrefwoorden( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String @@ -48,6 +53,10 @@ class ZgwDocumentTrefwoordResource( return ResponseEntity.ok(trefwoorden) } + @EndpointDescription( + en = "Search document trefwoorden for case definition", + nl = "Documenttrefwoorden voor dossierdefinitie zoeken", + ) @GetMapping("/management/v1/case-definition/{caseDefinitionName}/zgw-document/trefwoord") fun getTrefwoorden( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -58,6 +67,10 @@ class ZgwDocumentTrefwoordResource( return ResponseEntity.ok(page) } + @EndpointDescription( + en = "Create document trefwoord", + nl = "Documenttrefwoord aanmaken", + ) @PostMapping("/management/v1/case-definition/{caseDefinitionName}/zgw-document/trefwoord/{trefwoord}") fun createTrefwoord( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -67,6 +80,10 @@ class ZgwDocumentTrefwoordResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Delete document trefwoord", + nl = "Documenttrefwoord verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionName}/zgw-document/trefwoord/{trefwoord}") fun deleteTrefwoord( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, @@ -76,6 +93,10 @@ class ZgwDocumentTrefwoordResource( return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "Delete multiple document trefwoorden", + nl = "Meerdere documenttrefwoorden verwijderen", + ) @DeleteMapping("/management/v1/case-definition/{caseDefinitionName}/zgw-document/trefwoord") fun deleteTrefwoorden( @LoggableResource("documentDefinitionName") @PathVariable(name = "caseDefinitionName") caseDefinitionName: String, diff --git a/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiManagementResource.kt b/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiManagementResource.kt index 48d607977b..a6cd196421 100644 --- a/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiManagementResource.kt +++ b/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiManagementResource.kt @@ -21,6 +21,7 @@ import com.ritense.notificatiesapi.service.NotificatiesApiInboundEventQueryServi import com.ritense.notificatiesapi.web.dto.NotificatiesApiInboundEventResponse import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.http.ResponseEntity @@ -39,17 +40,29 @@ class NotificatiesApiManagementResource( private val inboundEventAdminService: NotificatiesApiInboundEventAdminService ) { + @EndpointDescription( + en = "List failed inbound Notificaties API events", + nl = "Mislukte inkomende Notificaties API-events ophalen", + ) @GetMapping("/v1/notificatiesapi/inbound-events/failed") fun getFailedEvents(pageable: Pageable): Page { return inboundEventQueryService.findFailedEvents(pageable) } + @EndpointDescription( + en = "Get failed inbound Notificaties API event count", + nl = "Aantal mislukte inkomende Notificaties API-events ophalen", + ) @GetMapping("/v1/notificatiesapi/inbound-events/failed/count") fun getFailedEventCount(): Map { val count = inboundEventAdminService.getFailedEventCount() return mapOf("count" to count) } + @EndpointDescription( + en = "Retry failed inbound Notificaties API event", + nl = "Mislukte inkomende Notificaties API-event opnieuw proberen", + ) @PostMapping("/v1/notificatiesapi/inbound-events/{id}/retry") fun retryFailedEvent(@PathVariable id: UUID): ResponseEntity { inboundEventAdminService.retryFailedEvent(id) diff --git a/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiResource.kt b/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiResource.kt index 00bab6e431..adf8a74432 100644 --- a/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiResource.kt +++ b/backend/zgw/notificaties-api/src/main/kotlin/com/ritense/notificatiesapi/web/rest/NotificatiesApiResource.kt @@ -21,6 +21,7 @@ import com.ritense.notificatiesapi.exception.AuthorizationException import com.ritense.notificatiesapi.service.NotificatiesApiService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -37,6 +38,10 @@ class NotificatiesApiResource( private val notificatiesApiService: NotificatiesApiService ) { + @EndpointDescription( + en = "Handle Notificaties API callback", + nl = "Notificaties API-callback verwerken", + ) @PostMapping("/v1/notificatiesapi/callback") fun handleNotification( @Valid @RequestBody notification: NotificatiesApiNotificationReceivedEvent, diff --git a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementConsumerResource.kt b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementConsumerResource.kt index e060b1ead3..838894c1c9 100644 --- a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementConsumerResource.kt +++ b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementConsumerResource.kt @@ -23,6 +23,7 @@ import com.ritense.objectmanagement.domain.search.SearchWithConfigRequest import com.ritense.objectmanagement.service.ObjectManagementService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl @@ -52,6 +53,10 @@ class ObjectManagementConsumerResource( private val objectManagementService: ObjectManagementService ) { + @EndpointDescription( + en = "Get objects for an object management configuration", + nl = "Objecten ophalen voor een objectbeheerconfiguratie", + ) @GetMapping("/objects") fun getObjects( @RequestParam(required = false) id: UUID?, @@ -70,10 +75,18 @@ class ObjectManagementConsumerResource( ) } + @EndpointDescription( + en = "List object management configurations available to the current user", + nl = "Objectbeheerconfiguraties ophalen die beschikbaar zijn voor de huidige gebruiker", + ) @GetMapping("/configuration") fun getConfigurations(): ResponseEntity> = ResponseEntity.ok(objectManagementService.getConfigurationsForUser()) + @EndpointDescription( + en = "List object instances for an object management configuration", + nl = "Objectinstanties ophalen voor een objectbeheerconfiguratie", + ) @GetMapping("/configuration/{id}/object") fun getObjectInstances( @PathVariable id: UUID, @@ -85,6 +98,10 @@ class ObjectManagementConsumerResource( } ) + @EndpointDescription( + en = "Search object instances for an object management configuration", + nl = "Objectinstanties zoeken voor een objectbeheerconfiguratie", + ) @PostMapping("/configuration/{id}/object") fun searchObjectInstances( @PathVariable id: UUID, diff --git a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementManagementResource.kt b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementManagementResource.kt index b144e64582..0746a39d15 100644 --- a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementManagementResource.kt +++ b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementManagementResource.kt @@ -21,6 +21,7 @@ import com.ritense.objectmanagement.domain.ObjectManagement import com.ritense.objectmanagement.service.ObjectManagementService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping @@ -33,6 +34,10 @@ class ObjectManagementManagementResource( private val objectManagementService: ObjectManagementService ) { + @EndpointDescription( + en = "List object management configurations", + nl = "Objectbeheerconfiguraties ophalen", + ) @GetMapping("/v1/object/management/configuration") @RunWithoutAuthorization fun getAll(): ResponseEntity> = ResponseEntity.ok(objectManagementService.getAll()) diff --git a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementResource.kt b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementResource.kt index 4cd00fe8a8..5c3f162743 100644 --- a/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementResource.kt +++ b/backend/zgw/object-management/src/main/kotlin/com/ritense/objectmanagement/web/rest/ObjectManagementResource.kt @@ -22,6 +22,7 @@ import com.ritense.objectmanagement.domain.search.SearchWithConfigRequest import com.ritense.objectmanagement.service.ObjectManagementService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable @@ -44,27 +45,51 @@ class ObjectManagementResource( private val objectManagementService: ObjectManagementService ) { + @EndpointDescription( + en = "Create object management configuration", + nl = "Objectbeheerconfiguratie aanmaken", + ) @PostMapping fun create(@Valid @RequestBody objectManagement: ObjectManagement): ResponseEntity = ResponseEntity.ok(objectManagementService.create(objectManagement)) + @EndpointDescription( + en = "Update object management configuration", + nl = "Objectbeheerconfiguratie bijwerken", + ) @PutMapping fun update(@Valid @RequestBody objectManagement: ObjectManagement): ResponseEntity = ResponseEntity.ok(objectManagementService.update(objectManagement)) + @EndpointDescription( + en = "Get object management configuration by id", + nl = "Objectbeheerconfiguratie op id ophalen", + ) @GetMapping("/{id}") fun getById(@PathVariable id: UUID): ResponseEntity = ResponseEntity.ok(objectManagementService.getById(id)) + @EndpointDescription( + en = "List object management configurations", + nl = "Objectbeheerconfiguraties ophalen", + ) @GetMapping fun getAll() = ResponseEntity.ok(objectManagementService.getAll()) + @EndpointDescription( + en = "Delete object management configuration", + nl = "Objectbeheerconfiguratie verwijderen", + ) @DeleteMapping("/{id}") fun delete(@PathVariable id: UUID): ResponseEntity { objectManagementService.deleteById(id) return ResponseEntity.noContent().build() } + @EndpointDescription( + en = "List objects for configuration", + nl = "Objecten voor configuratie ophalen", + ) @Deprecated( "To be removed in Valtimo 14. Use ObjectManagementConsumerResource: " + "GET /api/v1/object-management/configuration/{id}/object." @@ -76,6 +101,10 @@ class ObjectManagementResource( ): ResponseEntity> = ResponseEntity.ok(objectManagementService.getObjects(id, pageable)) + @EndpointDescription( + en = "Search objects with search fields", + nl = "Objecten met zoekvelden ophalen", + ) @Deprecated( "To be removed in Valtimo 14. Use ObjectManagementConsumerResource: " + "POST /api/v1/object-management/configuration/{id}/object." diff --git a/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ObjectResource.kt b/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ObjectResource.kt index 5f234c9c24..bdff6aa6f3 100644 --- a/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ObjectResource.kt +++ b/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ObjectResource.kt @@ -23,6 +23,7 @@ import com.ritense.objectenapi.service.ZaakObjectService import com.ritense.objectenapi.web.rest.result.FormType import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping @@ -40,6 +41,10 @@ class ObjectResource( private val zaakObjectService: ZaakObjectService ) { + @EndpointDescription( + en = "Get prefilled object form", + nl = "Vooraf ingevuld objectformulier ophalen", + ) @GetMapping("/form") fun getPrefilledObjectFromObjectUrl( @RequestParam(name = "objectUrl") objectUrl: URI? = null, @@ -51,6 +56,10 @@ class ObjectResource( return form?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Patch object", + nl = "Object bijwerken", + ) @PatchMapping fun patchObject( @RequestParam(name = "objectManagementId") objectManagementId: UUID, @@ -60,6 +69,10 @@ class ObjectResource( return ResponseEntity.ok(zaakObjectService.patchObjectFromManagementId(objectManagementId, objectId, jsonNode)) } + @EndpointDescription( + en = "Get object by url", + nl = "Object op url ophalen", + ) @GetMapping fun getObjectByUrl( @RequestParam(name = "objectUrl") objectUrl: URI): ResponseEntity = diff --git a/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ZaakObjectResource.kt b/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ZaakObjectResource.kt index e94e2d6e69..53fef35aa2 100644 --- a/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ZaakObjectResource.kt +++ b/backend/zgw/objecten-api/src/main/kotlin/com/ritense/objectenapi/web/rest/ZaakObjectResource.kt @@ -23,6 +23,7 @@ import com.ritense.objectenapi.web.rest.result.ObjectDto import com.ritense.objectenapi.web.rest.result.ObjecttypeDto import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller @@ -43,6 +44,10 @@ import java.util.UUID class ZaakObjectResource( private val zaakObjectService: ZaakObjectService ) { + @EndpointDescription( + en = "List zaak object types for document", + nl = "Zaakobjecttypes voor document ophalen", + ) @GetMapping("/v1/document/{documentId}/zaak/objecttype") fun getZaakObjecttypes( @PathVariable(name = "documentId") documentId: UUID @@ -53,6 +58,10 @@ class ZaakObjectResource( return ResponseEntity.ok(zaakObjectTypes) } + @EndpointDescription( + en = "List zaak objects for document", + nl = "Zaakobjecten voor document ophalen", + ) @GetMapping("/v1/document/{documentId}/zaak/object") fun getZaakObjecten( @PathVariable(name = "documentId") documentId: UUID, @@ -67,6 +76,10 @@ class ZaakObjectResource( message = "The documentId is not mandatory anymore", replaceWith = ReplaceWith("api/v1/object/form") ) + @EndpointDescription( + en = "Get object form by object URL", + nl = "Objectformulier op object-URL ophalen", + ) @GetMapping("/v1/document/{documentId}/zaak/object/form") fun getZaakObjecten( @RequestParam(name = "objectUrl") objectUrl: URI @@ -75,6 +88,10 @@ class ZaakObjectResource( return form?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Create object", + nl = "Object aanmaken", + ) @PostMapping("/v1/object") fun createZaakObject( @RequestParam(name = "objectManagementId") objectManagementId: UUID, @@ -90,6 +107,10 @@ class ZaakObjectResource( } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Update object", + nl = "Object bijwerken", + ) @PutMapping("/v1/object") fun updateZaakObject( @RequestParam(name = "objectManagementId") objectManagementId: UUID, @@ -106,6 +127,10 @@ class ZaakObjectResource( } ?: ResponseEntity.notFound().build() } + @EndpointDescription( + en = "Delete object", + nl = "Object verwijderen", + ) @DeleteMapping("/v1/object") fun deleteZaakObject( @RequestParam(name = "objectManagementId") objectManagementId: UUID, diff --git a/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/documentobjectenapisync/DocumentObjectenApiSyncManagementResource.kt b/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/documentobjectenapisync/DocumentObjectenApiSyncManagementResource.kt index 88a3aaae9f..10d56f69fe 100644 --- a/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/documentobjectenapisync/DocumentObjectenApiSyncManagementResource.kt +++ b/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/documentobjectenapisync/DocumentObjectenApiSyncManagementResource.kt @@ -21,6 +21,7 @@ import com.ritense.objectenapi.management.ObjectManagementInfoProvider import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -38,6 +39,10 @@ class DocumentObjectenApiSyncManagementResource( private val documentObjectenApiSyncManagementService: DocumentObjectenApiSyncManagementService, private val objectManagementInfoProvider: ObjectManagementInfoProvider, ) { + @EndpointDescription( + en = "Get Objecten API synchronisation configuration", + nl = "Objecten API-synchronisatieconfiguratie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/objecten-api-sync") fun getSyncConfiguration( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -56,6 +61,10 @@ class DocumentObjectenApiSyncManagementResource( ) } + @EndpointDescription( + en = "Create or update Objecten API synchronisation configuration", + nl = "Objecten API-synchronisatieconfiguratie aanmaken of bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/objecten-api-sync") fun createOrUpdateSyncConfiguration( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -68,6 +77,10 @@ class DocumentObjectenApiSyncManagementResource( return ResponseEntity.ok().build() } + @EndpointDescription( + en = "Delete Objecten API synchronisation configuration", + nl = "Objecten API-synchronisatieconfiguratie verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/objecten-api-sync") fun deleteSyncConfiguration( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/web/rest/CaseZaakdetailsInspectionResource.kt b/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/web/rest/CaseZaakdetailsInspectionResource.kt index ce79e1fb99..19fe5e726b 100644 --- a/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/web/rest/CaseZaakdetailsInspectionResource.kt +++ b/backend/zgw/zaakdetails/src/main/kotlin/com/ritense/zaakdetails/web/rest/CaseZaakdetailsInspectionResource.kt @@ -23,6 +23,7 @@ import com.ritense.document.service.JsonSchemaDocumentActionProvider import com.ritense.logging.LoggableResource import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.zaakdetails.service.CaseZaakdetailsInspectionService import com.ritense.zaakdetails.web.rest.dto.CaseZaakdetailsInspectionDto import com.ritense.zaakdetails.web.rest.dto.ZaakdetailsObjectContentDto @@ -45,6 +46,10 @@ class CaseZaakdetailsInspectionResource( private val caseZaakdetailsInspectionService: CaseZaakdetailsInspectionService, ) { + @EndpointDescription( + en = "Get zaakdetails inspection for case", + nl = "Zaakdetails-inspectie voor dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/zgw/zaakdetails") @Transactional fun getZaakdetailsInspection( @@ -55,6 +60,10 @@ class CaseZaakdetailsInspectionResource( return ResponseEntity.ok(caseZaakdetailsInspectionService.getInspection(caseId, document)) } + @EndpointDescription( + en = "Get zaakdetails object content for case", + nl = "Zaakdetails-objectinhoud voor dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/zgw/zaakdetails/object") @Transactional fun getZaakdetailsObjectContent( @@ -65,6 +74,10 @@ class CaseZaakdetailsInspectionResource( return ResponseEntity.ok(caseZaakdetailsInspectionService.getZaakdetailsObjectContent(caseId)) } + @EndpointDescription( + en = "Resolve zaakobject content for case", + nl = "Zaakobjectinhoud voor dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/zgw/zaakobject/resolve") @Transactional fun resolveZaakobjectContent( diff --git a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/sync/CaseZakenApiSyncManagementResource.kt b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/sync/CaseZakenApiSyncManagementResource.kt index d1defe0dbe..733d5f116e 100644 --- a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/sync/CaseZakenApiSyncManagementResource.kt +++ b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/sync/CaseZakenApiSyncManagementResource.kt @@ -20,6 +20,7 @@ import com.ritense.authorization.annotation.RunWithoutAuthorization import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -38,6 +39,10 @@ class CaseZakenApiSyncManagementResource( ) { @RunWithoutAuthorization + @EndpointDescription( + en = "Get Zaken API synchronisation configuration", + nl = "Zaken API-synchronisatieconfiguratie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/zaken-api-sync") fun getSyncConfiguration( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -50,6 +55,10 @@ class CaseZakenApiSyncManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Create or update Zaken API synchronisation configuration", + nl = "Zaken API-synchronisatieconfiguratie aanmaken of bijwerken", + ) @PutMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/zaken-api-sync") fun createOrUpdateSyncConfiguration( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, @@ -62,6 +71,10 @@ class CaseZakenApiSyncManagementResource( } @RunWithoutAuthorization + @EndpointDescription( + en = "Delete Zaken API synchronisation configuration", + nl = "Zaken API-synchronisatieconfiguratie verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{caseDefinitionVersionTag}/zaken-api-sync") fun deleteSyncConfiguration( @PathVariable("caseDefinitionKey") caseDefinitionKey: String, diff --git a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/uploadprocess/UploadProcessResource.kt b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/uploadprocess/UploadProcessResource.kt index 09e9483bb1..21a53a38e9 100644 --- a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/uploadprocess/UploadProcessResource.kt +++ b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/uploadprocess/UploadProcessResource.kt @@ -22,6 +22,7 @@ import com.ritense.logging.LoggableResource import com.ritense.processdocument.service.CaseDefinitionProcessLinkService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.zakenapi.uploadprocess.UploadProcessService.Companion.DOCUMENT_UPLOAD import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -38,6 +39,10 @@ class UploadProcessResource( ) { @Deprecated("Marked for removal since 9.22.0") + @EndpointDescription( + en = "Check case upload process link", + nl = "Dossieruploadproceskoppeling controleren", + ) @GetMapping("/v1/uploadprocess/case/{caseDefinitionName}/check-link") fun checkCaseProcessLink( @LoggableResource("documentDefinitionName") @PathVariable caseDefinitionName: String diff --git a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/CaseZgwInspectionResource.kt b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/CaseZgwInspectionResource.kt index ba6a856877..cb4f5103a6 100644 --- a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/CaseZgwInspectionResource.kt +++ b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/CaseZgwInspectionResource.kt @@ -29,6 +29,7 @@ import com.ritense.logging.LoggableResource import com.ritense.plugin.service.PluginService import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.zakenapi.ZakenApiPlugin import com.ritense.zakenapi.link.ZaakInstanceLinkNotFoundException import com.ritense.zakenapi.link.ZaakInstanceLinkService @@ -61,6 +62,10 @@ class CaseZgwInspectionResource( private val objectMapper: ObjectMapper, ) { + @EndpointDescription( + en = "Get ZGW inspection for case", + nl = "ZGW-inspectie voor dossier ophalen", + ) @GetMapping("/v1/case/{caseId}/zgw") @Transactional fun getZgwInspection( diff --git a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakDocumentResource.kt b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakDocumentResource.kt index f0488a8956..57631bdaa3 100644 --- a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakDocumentResource.kt +++ b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakDocumentResource.kt @@ -25,6 +25,7 @@ import com.ritense.logging.LoggableResource import com.ritense.plugin.domain.PluginConfiguration import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.zakenapi.domain.ZaakResponse import com.ritense.zakenapi.service.ZaakDocumentService import io.github.oshai.kotlinlogging.KotlinLogging @@ -53,6 +54,10 @@ class ZaakDocumentResource( private val zaakDocumentService: ZaakDocumentService ) { + @EndpointDescription( + en = "Get document files for zaak", + nl = "Documentbestanden voor zaak ophalen", + ) @GetMapping("/v1/zaken-api/document/{documentId}/files") fun getFiles( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable(name = "documentId") documentId: UUID @@ -60,6 +65,10 @@ class ZaakDocumentResource( return zaakDocumentService.getInformatieObjectenAsRelatedFiles(documentId) } + @EndpointDescription( + en = "Search document files page for zaak", + nl = "Documentbestandenpagina voor zaak zoeken", + ) @GetMapping("/v2/zaken-api/document/{documentId}/files") fun getFiles( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable(name = "documentId") documentId: UUID, @@ -69,6 +78,10 @@ class ZaakDocumentResource( return zaakDocumentService.getInformatieObjectenAsRelatedFilesPage(documentId, documentSearchRequest, pageable) } + @EndpointDescription( + en = "Get zaak metadata for document", + nl = "Zaakmetadata voor document ophalen", + ) @GetMapping("/v1/zaken-api/document/{documentId}/zaak") fun getZaakMetadata( @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable(name = "documentId") documentId: UUID @@ -76,6 +89,10 @@ class ZaakDocumentResource( return zaakDocumentService.getZaakByCaseDocumentId(documentId) } + @EndpointDescription( + en = "Delete document from zaak", + nl = "Document uit zaak verwijderen", + ) @DeleteMapping("/v1/zaken-api/{pluginConfigurationId}/case-document/{caseDocumentId}/files/{documentId}") fun deleteDocument( @LoggableResource(resourceType = PluginConfiguration::class) @PathVariable(name = "pluginConfigurationId") pluginConfigurationId: String, @@ -92,6 +109,10 @@ class ZaakDocumentResource( .build() } + @EndpointDescription( + en = "Modify document metadata for zaak", + nl = "Documentmetadata voor zaak bijwerken", + ) @PutMapping("/v1/zaken-api/{pluginConfigurationId}/case-document/{caseDocumentId}/files/{documentId}") fun modifyDocument( @PathVariable(name = "caseDocumentId") caseDocumentId: UUID, @@ -111,6 +132,10 @@ class ZaakDocumentResource( ) } + @EndpointDescription( + en = "Download document file for zaak", + nl = "Documentbestand voor zaak downloaden", + ) @GetMapping("/v1/zaken-api/{pluginConfigurationId}/case-document/{caseDocumentId}/files/{documentId}/download") fun downloadDocument( @PathVariable(name = "caseDocumentId") caseDocumentId: UUID, diff --git a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakTypeLinkResource.kt b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakTypeLinkResource.kt index 1c490a8e16..71cef34986 100644 --- a/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakTypeLinkResource.kt +++ b/backend/zgw/zaken-api/src/main/kotlin/com/ritense/zakenapi/web/rest/ZaakTypeLinkResource.kt @@ -18,6 +18,7 @@ package com.ritense.zakenapi.web.rest import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.endpoint.EndpointDescription import com.ritense.zakenapi.domain.ZaakTypeLink import com.ritense.zakenapi.web.rest.request.CreateZaakTypeLinkRequest import jakarta.validation.Valid @@ -35,15 +36,27 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/management", produces = [APPLICATION_JSON_UTF8_VALUE]) interface ZaakTypeLinkResource { + @EndpointDescription( + en = "Get zaaktype link for case definition version", + nl = "Zaaktypekoppeling voor dossierdefinitieversie ophalen", + ) @GetMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaak-type-link") fun get( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @PathVariable(name = "versionTag") versionTag: String ): ResponseEntity + @EndpointDescription( + en = "Get zaaktype link by process", + nl = "Zaaktypekoppeling op proces ophalen", + ) @GetMapping("/v1/zaak-type-link/process/{processDefinitionId}") fun getByProcess(@PathVariable(name = "processDefinitionId") processDefinitionId: String): ResponseEntity + @EndpointDescription( + en = "Create zaaktype link", + nl = "Zaaktypekoppeling aanmaken", + ) @PostMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaak-type-link") fun create( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, @@ -51,6 +64,10 @@ interface ZaakTypeLinkResource { @Valid @RequestBody request: CreateZaakTypeLinkRequest ): ResponseEntity + @EndpointDescription( + en = "Delete zaaktype link", + nl = "Zaaktypekoppeling verwijderen", + ) @DeleteMapping("/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/zaak-type-link") fun remove( @PathVariable(name = "caseDefinitionKey") caseDefinitionKey: String, diff --git a/frontend/CODING-GUIDELINES.md b/frontend/CODING-GUIDELINES.md index 4118e72284..7c97342ba1 100644 --- a/frontend/CODING-GUIDELINES.md +++ b/frontend/CODING-GUIDELINES.md @@ -546,6 +546,24 @@ export const MY_COMPONENT_TEST_IDS = { requirements out of the box, it is acceptable to create an improved wrapper in `@valtimo/components` (e.g. `SelectModule` wraps and improves on `cds-combobox`). +### Styling: use Carbon design tokens, not hardcoded values + +- **Always style with existing Carbon design tokens (CSS custom properties) and Carbon components. + Do not hardcode colours, spacing, type, or other design values.** Carbon exposes everything you + need as `var(--cds-*)` custom properties — use them so the UI stays theme- and dark-mode aware + and consistent with the rest of the admin UI. +- Spacing: `var(--cds-spacing-01 … --cds-spacing-13)` instead of raw pixel values + (e.g. `padding: var(--cds-spacing-05)`, not `padding: 16px`). +- Colour / surfaces: `var(--cds-layer)`, `var(--cds-layer-01/02)`, `var(--cds-background)`, + `var(--cds-border-subtle)`, `var(--cds-text-primary)`, `var(--cds-text-secondary)`, + `var(--cds-support-error/warning)`, `var(--cds-focus)`, etc. Never hardcode hex colours. +- Type: the Carbon type tokens (`var(--cds-body-compact-01-font-size)`, …) and the `cds-*` + typographic classes. +- Prefer a Carbon layout component or utility over bespoke CSS where one exists. +- A raw value is only acceptable when no Carbon token fits (e.g. a one-off `border-radius` or a + fixed icon box). In that rare case prefer `px` over `rem`, and add a short comment explaining why + no token applies. + ## Generated backend types TypeScript types for backend REST DTOs can be generated from the Java/Kotlin source code. Before diff --git a/frontend/apps/dev/src/app/app.module.ts b/frontend/apps/dev/src/app/app.module.ts index 6d734600c3..bc0715c867 100644 --- a/frontend/apps/dev/src/app/app.module.ts +++ b/frontend/apps/dev/src/app/app.module.ts @@ -88,6 +88,7 @@ import { documentenApiPluginSpecification, DocumentenApiPreviewPluginModule, documentenApiPreviewPluginSpecification, + ExternalPluginPageRoutingModule, KlantinteractiesApiPluginModule, klantinteractiesApiPluginSpecification, NotificatiesApiPluginModule, @@ -198,6 +199,7 @@ export function tabsFactory() { MigrationModule, // management PluginManagementModule, + ExternalPluginPageRoutingModule, ObjectManagementModule, ObjectModule, AccessControlManagementModule, diff --git a/frontend/apps/dev/src/environments/environment.prod.ts b/frontend/apps/dev/src/environments/environment.prod.ts index 16c42d401f..84a9eaba51 100644 --- a/frontend/apps/dev/src/environments/environment.prod.ts +++ b/frontend/apps/dev/src/environments/environment.prod.ts @@ -122,11 +122,14 @@ export const environment: ValtimoConfig = { {link: ['/admin-settings'], title: 'adminSettings.title'}, {link: ['/building-block-management'], title: 'buildingBlockManagement.title'}, {link: ['/case-management'], title: 'Cases'}, - {link: ['/plugins'], title: 'Plugins'}, {link: ['/dashboard-management'], title: 'Dashboard'}, {link: ['/access-control'], title: 'Access Control'}, {link: ['/translation-management'], title: 'Translations'}, {link: ['/choice-fields'], title: 'Choice fields'}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default'}, + {link: ['/plugins'], title: 'Plugins'}, + {link: ['/plugin-hosts'], title: 'Plugin hosts'}, + {link: ['/plugin-apps'], title: 'Apps'}, {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', includeFunction: IncludeFunction.ZgwFeaturesEnabled}, {link: ['/object-management'], title: 'Objects', includeFunction: IncludeFunction.ZgwFeaturesEnabled}, {link: ['/form-management'], title: 'Forms'}, diff --git a/frontend/apps/dev/src/environments/environment.ts b/frontend/apps/dev/src/environments/environment.ts index 57e98ea88a..88e32c717e 100644 --- a/frontend/apps/dev/src/environments/environment.ts +++ b/frontend/apps/dev/src/environments/environment.ts @@ -124,11 +124,14 @@ export const environment: ValtimoConfig = { {link: ['/admin-settings'], title: 'adminSettings.title'}, {link: ['/building-block-management'], title: 'buildingBlockManagement.title'}, {link: ['/case-management'], title: 'Cases'}, - {link: ['/plugins'], title: 'Plugins'}, {link: ['/dashboard-management'], title: 'Dashboard'}, {link: ['/access-control'], title: 'Access Control'}, {link: ['/translation-management'], title: 'Translations'}, {link: ['/choice-fields'], title: 'Choice fields'}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default'}, + {link: ['/plugins'], title: 'Plugins'}, + {link: ['/plugin-hosts'], title: 'Plugin hosts'}, + {link: ['/plugin-apps'], title: 'Apps'}, {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', includeFunction: IncludeFunction.ZgwFeaturesEnabled}, {link: ['/object-management'], title: 'Objects', includeFunction: IncludeFunction.ZgwFeaturesEnabled}, {link: ['/form-management'], title: 'Forms'}, diff --git a/frontend/apps/evenementenvergunning/src/environments/environment.prod.ts b/frontend/apps/evenementenvergunning/src/environments/environment.prod.ts index 17f70ef94a..19b2ff57d2 100644 --- a/frontend/apps/evenementenvergunning/src/environments/environment.prod.ts +++ b/frontend/apps/evenementenvergunning/src/environments/environment.prod.ts @@ -79,37 +79,40 @@ export const environment: ValtimoConfig = { sequence: 2, }, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, { title: 'Object management', textClass: 'text-dark font-weight-bold c-default', - sequence: 9, + sequence: 12, }, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, { link: ['/notifications-api/notifications/failed'], title: 'Failed notifications', - sequence: 12, + sequence: 15, }, { title: 'System processes', textClass: 'text-dark font-weight-bold c-default', - sequence: 13, + sequence: 16, }, - {link: ['/processes'], title: 'Processes', sequence: 14}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 17}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 18}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 16}, - {link: ['/logging'], title: 'Logs', sequence: 17}, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 18}, - {link: ['/process-migration'], title: 'Process migration', sequence: 19}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 19}, + {link: ['/logging'], title: 'Logs', sequence: 20}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 21}, + {link: ['/process-migration'], title: 'Process migration', sequence: 22}, ], }, { diff --git a/frontend/apps/evenementenvergunning/src/environments/environment.ts b/frontend/apps/evenementenvergunning/src/environments/environment.ts index ff12518066..d114b06a0a 100644 --- a/frontend/apps/evenementenvergunning/src/environments/environment.ts +++ b/frontend/apps/evenementenvergunning/src/environments/environment.ts @@ -95,43 +95,46 @@ export const environment: ValtimoConfig = { sequence: 2, }, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, { title: 'Object management', textClass: 'text-dark font-weight-bold c-default', - sequence: 9, + sequence: 12, }, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, { link: ['/notifications-api/notifications/failed'], title: 'Failed notifications', - sequence: 12, + sequence: 15, }, { title: 'System processes', textClass: 'text-dark font-weight-bold c-default', - sequence: 13, + sequence: 16, }, - {link: ['/processes'], title: 'Processes', sequence: 14}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 17}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 18}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 16}, - {link: ['/logging'], title: 'Logs', sequence: 17}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 19}, + {link: ['/logging'], title: 'Logs', sequence: 20}, { link: ['/opensearch'], title: 'adminSettings.opensearch.title', - sequence: 18, + sequence: 21, includeFunction: IncludeFunction.OpenSearchEnabled, }, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 19}, - {link: ['/process-migration'], title: 'Process migration', sequence: 20}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 22}, + {link: ['/process-migration'], title: 'Process migration', sequence: 23}, ], }, { diff --git a/frontend/apps/gzac/src/app/app.module.ts b/frontend/apps/gzac/src/app/app.module.ts index 9446e8b96b..9b1e4d034c 100644 --- a/frontend/apps/gzac/src/app/app.module.ts +++ b/frontend/apps/gzac/src/app/app.module.ts @@ -84,6 +84,7 @@ import { documentenApiPluginSpecification, DocumentenApiPreviewPluginModule, documentenApiPreviewPluginSpecification, + ExternalPluginPageRoutingModule, NotificatiesApiPluginModule, notificatiesApiPluginSpecification, ObjectenApiPluginModule, @@ -189,6 +190,7 @@ export function tabsFactory() { MigrationModule, // management PluginManagementModule, + ExternalPluginPageRoutingModule, ObjectManagementModule, ObjectModule, AccessControlManagementModule, diff --git a/frontend/apps/gzac/src/environments/environment.prod.ts b/frontend/apps/gzac/src/environments/environment.prod.ts index 5b49ae1243..b52a920425 100644 --- a/frontend/apps/gzac/src/environments/environment.prod.ts +++ b/frontend/apps/gzac/src/environments/environment.prod.ts @@ -79,37 +79,40 @@ export const environment: ValtimoConfig = { sequence: 2, }, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, { title: 'Object management', textClass: 'text-dark font-weight-bold c-default', - sequence: 9, + sequence: 12, }, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, { link: ['/notifications-api/notifications/failed'], title: 'Failed notifications', - sequence: 12, + sequence: 15, }, { title: 'System processes', textClass: 'text-dark font-weight-bold c-default', - sequence: 13, + sequence: 16, }, - {link: ['/processes'], title: 'Processes', sequence: 14}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 17}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 18}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 16}, - {link: ['/logging'], title: 'Logs', sequence: 17}, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 18}, - {link: ['/process-migration'], title: 'Process migration', sequence: 19}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 19}, + {link: ['/logging'], title: 'Logs', sequence: 20}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 21}, + {link: ['/process-migration'], title: 'Process migration', sequence: 22}, ], }, { diff --git a/frontend/apps/gzac/src/environments/environment.ts b/frontend/apps/gzac/src/environments/environment.ts index 30ef5b64e4..22e54fc269 100644 --- a/frontend/apps/gzac/src/environments/environment.ts +++ b/frontend/apps/gzac/src/environments/environment.ts @@ -97,43 +97,46 @@ export const environment: ValtimoConfig = { sequence: 2, }, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, { title: 'Object management', textClass: 'text-dark font-weight-bold c-default', - sequence: 9, + sequence: 12, }, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, { link: ['/notifications-api/notifications/failed'], title: 'Failed notifications', - sequence: 12, + sequence: 15, }, { title: 'System processes', textClass: 'text-dark font-weight-bold c-default', - sequence: 13, + sequence: 16, }, - {link: ['/processes'], title: 'Processes', sequence: 14}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 17}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 18}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 16}, - {link: ['/logging'], title: 'Logs', sequence: 17}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 19}, + {link: ['/logging'], title: 'Logs', sequence: 20}, { link: ['/opensearch'], title: 'adminSettings.opensearch.title', - sequence: 18, + sequence: 21, includeFunction: IncludeFunction.OpenSearchEnabled, }, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 19}, - {link: ['/process-migration'], title: 'Process migration', sequence: 20}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 22}, + {link: ['/process-migration'], title: 'Process migration', sequence: 23}, ], }, { diff --git a/frontend/apps/valtimo/src/app/app.module.ts b/frontend/apps/valtimo/src/app/app.module.ts index ea4c78ffeb..c04b4b7013 100644 --- a/frontend/apps/valtimo/src/app/app.module.ts +++ b/frontend/apps/valtimo/src/app/app.module.ts @@ -1,7 +1,12 @@ import {BrowserModule} from '@angular/platform-browser'; import {Injector, NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {HttpBackend, HttpClient, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import { + HttpBackend, + HttpClient, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import {AppRoutingModule} from './app-routing.module'; import {AppComponent} from './app.component'; import {LayoutModule, TranslationManagementModule} from '@valtimo/layout'; @@ -68,6 +73,7 @@ import {TranslateLoader, TranslateModule} from '@ngx-translate/core'; import {FormFlowManagementModule} from '@valtimo/form-flow-management'; import {PluginManagementModule} from '@valtimo/plugin-management'; import { + ExternalPluginPageRoutingModule, ObjectenApiPluginModule, objectenApiPluginSpecification, ObjectTokenAuthenticationPluginModule, @@ -90,14 +96,12 @@ export function tabsFactory() { [DefaultTabs.progress, CaseDetailTabProgressComponent], [DefaultTabs.audit, CaseDetailTabAuditComponent], [DefaultTabs.documents, CaseDetailTabDocumentsComponent], - [DefaultTabs.notes, CaseDetailTabNotesComponent] + [DefaultTabs.notes, CaseDetailTabNotesComponent], ]); } @NgModule({ - declarations: [ - AppComponent - ], + declarations: [AppComponent], bootstrap: [AppComponent], imports: [ AccessControlManagementModule, @@ -124,6 +128,7 @@ export function tabsFactory() { DecisionModule, DisplayWidgetTypesModule, DocumentModule, + ExternalPluginPageRoutingModule, FormFlowManagementModule, FormManagementModule, FormModule, @@ -159,7 +164,7 @@ export function tabsFactory() { provide: TranslateLoader, useFactory: CustomMultiTranslateHttpLoaderFactory, deps: [HttpBackend, HttpClient, ConfigService, LocalizationService], - } + }, }), ], providers: [ @@ -168,11 +173,11 @@ export function tabsFactory() { useValue: [ objectenApiPluginSpecification, objecttypenApiPluginSpecification, - objectTokenAuthenticationPluginSpecification - ] + objectTokenAuthenticationPluginSpecification, + ], }, - provideHttpClient(withInterceptorsFromDi()) - ] + provideHttpClient(withInterceptorsFromDi()), + ], }) export class AppModule { constructor(injector: Injector) { diff --git a/frontend/apps/valtimo/src/environments/environment.prod.ts b/frontend/apps/valtimo/src/environments/environment.prod.ts index 1b932ff648..18d00ea14f 100644 --- a/frontend/apps/valtimo/src/environments/environment.prod.ts +++ b/frontend/apps/valtimo/src/environments/environment.prod.ts @@ -26,24 +26,27 @@ export const environment: ValtimoConfig = { {link: ['/admin-settings'], title: 'adminSettings.title'}, {link: ['/building-block-management'], title: 'buildingBlockManagement.title', sequence: 2}, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, - {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', sequence: 9}, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', sequence: 12}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, - {title: 'System processes', textClass: 'text-dark font-weight-bold c-default', sequence: 12}, - {link: ['/processes'], title: 'Processes', sequence: 13}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 14}, + {title: 'System processes', textClass: 'text-dark font-weight-bold c-default', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 16}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 17}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 15}, - {link: ['/logging'], title: 'Logs', sequence: 16}, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 17}, - {link: ['/process-migration'], title: 'Process migration', sequence: 18}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 18}, + {link: ['/logging'], title: 'Logs', sequence: 19}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 20}, + {link: ['/process-migration'], title: 'Process migration', sequence: 21}, ], }, { diff --git a/frontend/apps/valtimo/src/environments/environment.ts b/frontend/apps/valtimo/src/environments/environment.ts index 2cd6398800..9ac5487664 100644 --- a/frontend/apps/valtimo/src/environments/environment.ts +++ b/frontend/apps/valtimo/src/environments/environment.ts @@ -42,30 +42,33 @@ export const environment: ValtimoConfig = { {link: ['/admin-settings'], title: 'adminSettings.title'}, {link: ['/building-block-management'], title: 'buildingBlockManagement.title', sequence: 2}, {link: ['/case-management'], title: 'Cases', sequence: 3}, - {link: ['/plugins'], title: 'Plugins', sequence: 4}, - {link: ['/dashboard-management'], title: 'Dashboard', sequence: 5}, - {link: ['/access-control'], title: 'Access Control', sequence: 6}, - {link: ['/translation-management'], title: 'Translations', sequence: 7}, - {link: ['/choice-fields'], title: 'Choice fields', sequence: 8}, + {link: ['/dashboard-management'], title: 'Dashboard', sequence: 4}, + {link: ['/access-control'], title: 'Access Control', sequence: 5}, + {link: ['/translation-management'], title: 'Translations', sequence: 6}, + {link: ['/choice-fields'], title: 'Choice fields', sequence: 7}, + {title: 'Integrations', textClass: 'text-dark font-weight-bold c-default', sequence: 8}, + {link: ['/plugins'], title: 'Plugins', sequence: 9}, + {link: ['/plugin-hosts'], title: 'Plugin hosts', sequence: 10}, + {link: ['/plugin-apps'], title: 'Apps', sequence: 11}, - {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', sequence: 9}, - {link: ['/object-management'], title: 'Objects', sequence: 10}, - {link: ['/form-management'], title: 'Forms', sequence: 11}, + {title: 'Object management', textClass: 'text-dark font-weight-bold c-default', sequence: 12}, + {link: ['/object-management'], title: 'Objects', sequence: 13}, + {link: ['/form-management'], title: 'Forms', sequence: 14}, - {title: 'System processes', textClass: 'text-dark font-weight-bold c-default', sequence: 12}, - {link: ['/processes'], title: 'Processes', sequence: 13}, - {link: ['/decision-tables'], title: 'Decision tables', sequence: 14}, + {title: 'System processes', textClass: 'text-dark font-weight-bold c-default', sequence: 15}, + {link: ['/processes'], title: 'Processes', sequence: 16}, + {link: ['/decision-tables'], title: 'Decision tables', sequence: 17}, - {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 15}, - {link: ['/logging'], title: 'Logs', sequence: 16}, + {title: 'Other', textClass: 'text-dark font-weight-bold c-default', sequence: 18}, + {link: ['/logging'], title: 'Logs', sequence: 19}, { link: ['/opensearch'], title: 'adminSettings.opensearch.title', - sequence: 17, + sequence: 20, includeFunction: IncludeFunction.OpenSearchEnabled, }, - {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 18}, - {link: ['/process-migration'], title: 'Process migration', sequence: 19}, + {link: ['/case-migration'], title: 'Case migration (beta)', sequence: 21}, + {link: ['/process-migration'], title: 'Process migration', sequence: 22}, ], }, { diff --git a/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.html b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.html new file mode 100644 index 0000000000..694b419229 --- /dev/null +++ b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.html @@ -0,0 +1,344 @@ + + +@if ($loading()) { + +} @else { + +} + + + + + + + + + @if (isContainer(node)) { + + } + + + @if (hasRuntimeSubmenu(node)) { + + } + + + + + + + + + + + + + + + + + + +

{{ 'adminSettings.menuConfiguration.editor.title' | translate }}

+
+ + + + + + + +
+ + + diff --git a/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.scss b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.scss new file mode 100644 index 0000000000..8b82e017d9 --- /dev/null +++ b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.scss @@ -0,0 +1,217 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.menu-configuration { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-05); + + &__status { + display: flex; + justify-content: center; + padding: var(--cds-spacing-07); + } + + // fitPage sizes this grid to the remaining viewport height; stretching the columns makes both + // panels exactly that height (so they always match), each scrolling its own content internally. + &__panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--cds-spacing-06); + align-items: stretch; + min-height: 0; + } + + // While dragging, recede the sections that cannot accept the dragged item. Dim only the *direct* + // rows (and dummy submenus) of a non-receiving list — never the list element itself, whose opacity + // would cascade onto nested lists, washing out a valid sub-list you are reordering within. + // ::ng-deep because .valtimo-drag-drop-list is a child component's host, out of reach of this + // component's emulated encapsulation — still scoped by the --dragging ancestor. + &__panels--dragging { + ::ng-deep + .valtimo-drag-drop-list:not(.cdk-drop-list-receiving):not(.cdk-drop-list-dragging) + > .valtimo-drag-drop-list__item { + > .valtimo-drag-drop-list__row, + > .valtimo-drag-drop-list__expansion > .menu-configuration__children--runtime { + opacity: 0.4; + transition: opacity 150ms ease; + } + } + } + + // Each panel is its own bounded scroll container, so the menu structure and the available-items + // catalog scroll independently (and CDK auto-scrolls the panel you drag into). The sticky header + // pins to the top of the scroll area and carries its own padding/background. + &__panel { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-04); + padding: 0 var(--cds-spacing-05) var(--cds-spacing-05); + border: 1px solid var(--cds-border-subtle); + // Stretched to the grid's fitPage height (both panels match); scroll content within. + height: 100%; + min-height: 0; + overflow-y: auto; + background-color: var(--cds-layer-01); + } + + // Sticky panel header: title on the left, actions (Reset / Save) on the right for the structure panel. + &__panel-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cds-spacing-05); + min-height: var(--cds-spacing-09); + padding: var(--cds-spacing-05) 0 var(--cds-spacing-05); + background-color: var(--cds-layer); + } + + &__panel-title { + color: var(--cds-text-primary); + font-weight: 600; + } + + &__panel-actions { + display: flex; + gap: var(--cds-spacing-03); + flex-shrink: 0; + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cds-spacing-03); + width: 100%; + min-width: 0; + } + + &__row-main { + display: flex; + align-items: center; + gap: var(--cds-spacing-03); + min-width: 0; + } + + &__row-icon { + color: var(--cds-icon-primary); + flex-shrink: 0; + } + + &__row-title { + color: var(--cds-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + // Raw value: `overflow: hidden` clips to the line box, so the line box must be tall enough to + // keep descenders (g, j, p) from being cut off — no Carbon token maps cleanly to this. + line-height: 1.4; + + // Container rows (Admin, Development, custom sections) read as section headers. + &--group { + font-weight: 600; + } + } + + &__row-route { + color: var(--cds-text-secondary); + font-size: var(--cds-code-01-font-size, var(--cds-body-compact-01-font-size)); + font-family: var(--cds-code-01-font-family, monospace); + } + + &__row-actions { + display: flex; + flex-shrink: 0; + } + + // A section's children read through indentation and a guide rail (not a boxed container). The drop + // affordance appears on the inner list only while dragging or when the section is empty (see + // valtimo-drag-drop-list). + &__children { + margin-top: var(--cds-spacing-03); + margin-left: var(--cds-spacing-06); + padding-left: var(--cds-spacing-04); + border-left: var(--cds-spacing-01) solid var(--cds-border-subtle); + } + + // The dummy submenu under a dynamic item (Cases, Objects): the same indented rail, but holding an + // info notification instead of a droppable list — it never accepts items. + &__children--runtime { + cds-inline-notification, + .cds--inline-notification { + min-width: 0; + margin-block: 0; + } + } + + &__structure-empty, + &__palette-empty { + color: var(--cds-text-helper); + padding: var(--cds-spacing-04); + text-align: center; + } + + &__palette-group { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-03); + } + + &__palette-group-title { + color: var(--cds-text-secondary); + text-transform: uppercase; + font-size: var(--cds-label-01-font-size); + letter-spacing: var(--cds-label-01-letter-spacing); + margin-top: var(--cds-spacing-03); + } + + &__palette-item { + display: flex; + align-items: center; + gap: var(--cds-spacing-03); + width: 100%; + min-width: 0; + } + + &__palette-label { + flex: 1 1 auto; + color: var(--cds-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + // See __row-title: keep the line box tall enough that descenders are not clipped by overflow. + line-height: 1.4; + } + + &__editor { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-05); + } + + &__editor-note { + color: var(--cds-text-helper); + font-style: italic; + } +} + +:host ::ng-deep cds-inline-notification { + max-inline-size: unset; + width: 100%; +} diff --git a/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.ts b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.ts new file mode 100644 index 0000000000..33cc01eb5f --- /dev/null +++ b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings-menu-configuration/admin-settings-menu-configuration.component.ts @@ -0,0 +1,876 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import { + CdkDrag, + CdkDragDrop, + CdkDropList, + moveItemInArray, + transferArrayItem, +} from '@angular/cdk/drag-drop'; +import {ScrollingModule} from '@angular/cdk/scrolling'; +import { + ChangeDetectionStrategy, + Component, + computed, + OnDestroy, + OnInit, + signal, +} from '@angular/core'; +import {takeUntilDestroyed, toObservable, toSignal} from '@angular/core/rxjs-interop'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import { + AdminSettingsService, + buildMenuConfigurationFromRuntimeMenu, + ConfirmationModalModule, + DragDropListComponent, + FitPageDirective, + getMenuCatalogEntry, + hasSavedMenuConfiguration, + MdiIconSelectorComponent, + MENU_CONFIGURATION_VERSION, + MENU_ITEM_CATALOG, + MenuConfiguration, + MenuConfigurationItem, + MenuItemPlacement, + serializeIncludeFunction, + TooltipModule, +} from '@valtimo/components'; +import {ConfigService, IncludeFunction} from '@valtimo/shared'; +import {ExternalPluginMenuPage, ExternalPluginPageService} from '@valtimo/plugin'; +import { + ButtonModule, + IconModule, + IconService, + InputModule, + LayerModule, + LoadingModule, + ModalModule, + NotificationModule, + SelectModule, + TagModule, +} from 'carbon-components-angular'; +import {Add16, Edit16, Locked16, TrashCan16} from '@carbon/icons'; +import {catchError, forkJoin, merge, of} from 'rxjs'; +import {MENU_CONFIGURATION_TEST_IDS} from '../../constants'; +import {BuilderNode, PaletteGroup, PaletteItem} from '../../models'; + +@Component({ + standalone: true, + selector: 'valtimo-admin-settings-menu-configuration', + templateUrl: './admin-settings-menu-configuration.component.html', + styleUrls: ['./admin-settings-menu-configuration.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + ScrollingModule, + DragDropListComponent, + FitPageDirective, + MdiIconSelectorComponent, + ConfirmationModalModule, + TooltipModule, + ButtonModule, + IconModule, + InputModule, + LayerModule, + LoadingModule, + ModalModule, + NotificationModule, + SelectModule, + TagModule, + ], +}) +export class AdminSettingsMenuConfigurationComponent implements OnInit, OnDestroy { + protected readonly testIds = MENU_CONFIGURATION_TEST_IDS; + + public readonly $loading = signal(true); + public readonly $saving = signal(false); + public readonly $saved = signal(false); + public readonly $structure = signal([]); + + /** Drives the post-save "reload now?" confirmation modal. */ + private readonly _$reloadModalOpen = signal(false); + public readonly reloadModalOpen$ = toObservable(this._$reloadModalOpen); + public readonly $pluginPages = signal>([]); + + /** True while a drag is in progress, so the panels can desaturate the sections that cannot receive it. */ + public readonly $dragging = signal(false); + + public readonly $editorOpen = signal(false); + public readonly $editorKind = signal('catalog'); + /** True while editing a required item (Admin/Settings) — its Include-function field is suppressed. */ + public readonly $editorRequired = signal(false); + + /** + * Bumped whenever ngx-translate (re)loads, so the translated palette/section labels — resolved + * imperatively via `translateService.instant` inside computeds — recompute once translations arrive + * (a plain computed would otherwise keep the raw keys captured on first, pre-load evaluation). + */ + private readonly _$translationTick = signal(0); + + public readonly $paletteGroups = computed(() => { + this._$translationTick(); + return this._buildPaletteGroups(); + }); + + /** + * All structure drop-list ids (root + every container), so each list — including palette lists and + * the nested container lists — connects to every structure list **by id**. CDK resolves these ids + * globally, so connections work regardless of DOM/DI nesting (a `cdkDropListGroup` would not see a + * nested list whose template is declared outside the group element). + */ + public readonly $structureListIds = computed(() => { + // Container lists are listed BEFORE the root list so that, where a nested section's drop zone + // overlaps the root list's area, CDK resolves to the inner (section) list first. The result: + // dropping inside a section's box lands in that section, while the section header row and the + // top-level gaps (covered only by the root list) still drop at the top level. + const containerIds: string[] = []; + this._walk(this.$structure(), node => { + if (this.isContainer(node)) containerIds.push(this.containerListId(node)); + }); + return [...containerIds, 'structure-root']; + }); + + public readonly includeFunctionKeys: string[] = Object.keys(IncludeFunction).filter(key => + isNaN(Number(key)) + ); + + public readonly editorForm = this.fb.group({ + title: this.fb.control('', Validators.required), + link: this.fb.control(''), + icon: this.fb.control(null), + includeFunction: this.fb.control(null), + section: this.fb.control('root'), + }); + + /** Sections an item can be placed in via the editor: top level + each container present in the tree. */ + public readonly $sectionOptions = computed>(() => { + this._$translationTick(); + const options = [ + { + value: 'root', + label: this.translateService.instant('adminSettings.menuConfiguration.editor.sectionRoot'), + }, + ]; + this.$structure().forEach(node => { + if (this.isContainer(node)) + options.push({value: this._sectionValue(node), label: this.displayTitle(node)}); + }); + return options; + }); + + /** Kinds that can be freely placed in any section (and so get the editor's Section selector). */ + private readonly SECTION_CAPABLE_KINDS: ReadonlyArray = [ + 'custom-link', + 'section-header', + 'plugin-page', + ]; + + public readonly $editorShowSection = computed(() => + this.SECTION_CAPABLE_KINDS.includes(this.$editorKind()) + ); + + /** Tracks the editor's currently selected section so the icon field can react to placement changes. */ + private readonly _$editorSection = toSignal(this.editorForm.controls.section.valueChanges, { + initialValue: this.editorForm.controls.section.value, + }); + + /** + * The icon only surfaces in the rendered menu for top-level entries, so the editor hides the icon + * field for section headers and for any item placed inside a section (a sub-menu item). + */ + public readonly $editorShowIcon = computed( + () => this.$editorKind() !== 'section-header' && this._$editorSection() === 'root' + ); + + private _editingUid: string | null = null; + private _pendingPaletteItem: PaletteItem | null = null; + private _uidCounter = 0; + + // Drop predicates — placement constraints. Every predicate also defers to a deeper nested list under + // the pointer (see `_pointerOverDeeperList`), so the innermost list always wins: without this, the + // root list — which geometrically encloses every nested list — would steal freely-placeable ('any') + // items (custom links, section headers, plugin pages) out of a section while you reorder inside it, + // because root accepts them (catalog items are protected only by their placement constraint). + // Palette lists reject all drops (you drag *out* of them). + public readonly topEnterPredicate = (drag: CdkDrag, drop: CdkDropList): boolean => + !this._pointerOverDeeperList(drop) && ['top', 'any'].includes(this._placementOf(drag.data)); + public readonly adminEnterPredicate = (drag: CdkDrag, drop: CdkDropList): boolean => + !this._pointerOverDeeperList(drop) && this._containerAccepts('admin', drag.data); + public readonly developmentEnterPredicate = (drag: CdkDrag, drop: CdkDropList): boolean => + !this._pointerOverDeeperList(drop) && this._containerAccepts('development', drag.data); + // A custom section only accepts freely-placeable ('any') items — never another section or a + // placement-constrained catalog page — so the menu never nests beyond the two levels it renders. + public readonly groupEnterPredicate = (drag: CdkDrag, drop: CdkDropList): boolean => + !this._pointerOverDeeperList(drop) && this._placementOf(drag.data) === 'any'; + public readonly paletteEnterPredicate = (): boolean => false; + + /** Last known pointer position during a drag, used to resolve nested drop-list ambiguity. */ + private _pointer = {x: 0, y: 0}; + private readonly _trackPointer = (event: PointerEvent | TouchEvent): void => { + const point = 'touches' in event ? event.touches[0] : event; + if (point) this._pointer = {x: point.clientX, y: point.clientY}; + }; + + /** + * True when a `.valtimo-drag-drop-list` nested *inside* `drop` currently sits under the pointer. + * Such a deeper list is the more specific target, so `drop` should decline the item — this keeps a + * reorder inside a section from being hijacked by the enclosing (ancestor) list. + */ + private _pointerOverDeeperList(drop: CdkDropList): boolean { + const nested = (drop.element.nativeElement as HTMLElement).querySelectorAll( + '.valtimo-drag-drop-list' + ); + return Array.from(nested).some(list => { + const r = list.getBoundingClientRect(); + return ( + this._pointer.x >= r.left && + this._pointer.x <= r.right && + this._pointer.y >= r.top && + this._pointer.y <= r.bottom + ); + }); + } + + constructor( + private readonly fb: FormBuilder, + private readonly adminSettingsService: AdminSettingsService, + private readonly pluginPageService: ExternalPluginPageService, + private readonly configService: ConfigService, + private readonly translateService: TranslateService, + private readonly iconService: IconService + ) { + this.iconService.registerAll([Add16, Edit16, Locked16, TrashCan16]); + + merge( + this.translateService.onLangChange, + this.translateService.onDefaultLangChange, + this.translateService.onTranslationChange + ) + .pipe(takeUntilDestroyed()) + .subscribe(() => this._$translationTick.update(tick => tick + 1)); + } + + public ngOnInit(): void { + this.load(); + } + + public ngOnDestroy(): void { + // A drag can still be in progress when the component is destroyed (route change mid-drag), in + // which case onDragEnded never fires — always detach the document-level pointer listeners. + this._removePointerListeners(); + } + + // ----- Loading & seeding ----- + + public load(): void { + this.$loading.set(true); + forkJoin({ + dto: this.adminSettingsService + .getMenuConfiguration() + .pipe(catchError(() => of({configuration: {}}))), + pages: this.pluginPageService.getMenuPages().pipe(catchError(() => of([]))), + }).subscribe(({dto, pages}) => { + this.$pluginPages.set(pages); + const items = hasSavedMenuConfiguration(dto) + ? (dto.configuration.items as MenuConfigurationItem[]) + : buildMenuConfigurationFromRuntimeMenu(this.configService.config?.menu?.menuItems ?? []) + .items; + this.$structure.set(this._assignUids(items)); + this.$loading.set(false); + }); + } + + public onReset(): void { + this.load(); + } + + // ----- Drag & drop ----- + + public onDragStarted(): void { + this.$dragging.set(true); + document.addEventListener('pointermove', this._trackPointer, true); + document.addEventListener('touchmove', this._trackPointer, true); + } + + public onDragEnded(): void { + this.$dragging.set(false); + this._removePointerListeners(); + } + + private _removePointerListeners(): void { + document.removeEventListener('pointermove', this._trackPointer, true); + document.removeEventListener('touchmove', this._trackPointer, true); + } + + public onDrop(event: CdkDragDrop): void { + const isFromPalette = event.previousContainer.id.startsWith('palette-'); + + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else if (isFromPalette) { + const node = this._paletteItemToNode(event.item.data as PaletteItem); + if (!node) return; + event.container.data.splice(event.currentIndex, 0, node); + } else { + transferArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex + ); + } + + this._bump(); + } + + // ----- Palette actions ----- + + public onAddPaletteItem(item: PaletteItem): void { + // Catalog items have a fixed placement — add them straight to the matching section. + if (item.paletteType === 'catalog') { + const node = this._paletteItemToNode(item); + if (!node) return; + this._defaultTargetFor(item).push(node); + this._bump(); + return; + } + + // Generic (custom link, section header, section/group) + plugin-page items open the editor so + // the admin can set the title and pick the section (top level, Admin, a custom section, …) + // before the item is committed to the tree. + this.openNewItemEditor(item); + } + + // ----- Row actions ----- + + public openEditor(node: BuilderNode): void { + this._editingUid = node._uid; + this._pendingPaletteItem = null; + this.$editorKind.set(node.kind); + this.$editorRequired.set(this.isRequired(node)); + + this.editorForm.reset({ + title: this._editableTitle(node), + link: node.kind === 'custom-link' ? node.link : this._displayRoute(node), + icon: this._mdiKeyFromIconClass(this._displayIconClass(node)), + // Empty string (not null) so the "Geen" option (value="") is selected on open when the item + // carries no include function — a null value would leave the native select blank. The stored + // value is the enum name (or a legacy numeric ordinal); the select works with names. + includeFunction: + node.kind === 'catalog' && !this.isRequired(node) + ? (serializeIncludeFunction(node.includeFunction) ?? '') + : '', + section: this._findSectionOf(node._uid), + }); + + this._syncLinkControl(node.kind); + this.$editorOpen.set(true); + } + + public openNewItemEditor(item: PaletteItem): void { + this._editingUid = null; + this._pendingPaletteItem = item; + this.$editorKind.set(item.paletteType); + this.$editorRequired.set(false); + + const defaultTitle = + item.paletteType === 'section-header' + ? this.translateService.instant('adminSettings.menuConfiguration.defaults.sectionHeader') + : item.paletteType === 'custom-link' + ? this.translateService.instant('adminSettings.menuConfiguration.defaults.customLink') + : item.paletteType === 'group' + ? this.translateService.instant('adminSettings.menuConfiguration.defaults.group') + : item.label; + + this.editorForm.reset({ + title: defaultTitle, + link: item.paletteType === 'custom-link' ? '/' : '', + icon: item.icon ? this._mdiKeyFromIconClass(item.icon) : null, + includeFunction: '', + section: 'root', + }); + + this._syncLinkControl(item.paletteType); + this.$editorOpen.set(true); + } + + public onEditorSave(): void { + if (this.editorForm.invalid) return; + const value = this.editorForm.getRawValue(); + + const node = + this._editingUid !== null + ? this._findByUid(this.$structure(), this._editingUid) + : this._pendingPaletteItem + ? this._paletteItemToNode(this._pendingPaletteItem) + : null; + if (!node) { + this.closeEditor(); + return; + } + + node.title = (value.title ?? '').trim(); + if (node.kind === 'custom-link') { + node.link = (value.link ?? '').trim(); + } + if (node.kind === 'catalog' || node.kind === 'custom-link' || node.kind === 'plugin-page') { + const iconClass = this._iconClassFromMdiKey(value.icon); + if (iconClass) { + node.icon = iconClass; + } else if (this._editingUid !== null) { + delete node.icon; + } + } + if (node.kind === 'catalog') { + // Required items must never carry an include function (it could hide them at runtime). + // Persisted as the enum member *name* so the stored config survives enum reorders. + node.includeFunction = + this.isRequired(node) || !value.includeFunction + ? undefined + : serializeIncludeFunction(value.includeFunction); + if (node.includeFunction === undefined) delete node.includeFunction; + } + + this._placeNode(node, value.section ?? 'root'); + this._bump(); + this.closeEditor(); + } + + public closeEditor(): void { + this.$editorOpen.set(false); + this._editingUid = null; + this._pendingPaletteItem = null; + } + + /** Commits a (new or edited) node into the chosen section, moving it if its section changed. */ + private _placeNode(node: BuilderNode, section: string): void { + const sectionCapable = this.SECTION_CAPABLE_KINDS.includes(node.kind); + + if (this._editingUid !== null) { + // Existing node: only move it when it is section-capable and the section actually changed. + if (sectionCapable && this._findSectionOf(node._uid) !== section) { + this._removeByUid(this.$structure(), node._uid); + this._targetForSection(section).push(node); + } + return; + } + + // New node: place into the chosen section (top level for non-section-capable kinds). + const target = sectionCapable ? this._targetForSection(section) : this.$structure(); + target.push(node); + } + + private _syncLinkControl(kind: MenuConfigurationItem['kind']): void { + if (kind === 'custom-link') { + this.editorForm.controls.link.enable(); + } else { + this.editorForm.controls.link.disable(); + } + } + + public onRemove(node: BuilderNode): void { + if (this.isRequired(node)) return; + this._removeByUid(this.$structure(), node._uid); + this._bump(); + } + + public onSave(): void { + this.$saving.set(true); + this.$saved.set(false); + const configuration: MenuConfiguration = { + version: MENU_CONFIGURATION_VERSION, + items: this._stripUids(this.$structure()), + }; + this.adminSettingsService.updateMenuConfiguration({configuration}).subscribe({ + next: () => { + this.$saving.set(false); + this._$reloadModalOpen.set(true); + }, + error: () => this.$saving.set(false), + }); + } + + /** Reload the app so the freshly saved menu takes effect. */ + public onReloadConfirm(): void { + window.location.reload(); + } + + /** Dismiss the reload modal, leaving a persistent inline reminder that a reload is still pending. */ + public onReloadDismiss(): void { + this._$reloadModalOpen.set(false); + this.$saved.set(true); + } + + /** Dismiss the persistent "menu saved" reminder. */ + public onDismissSavedNotification(): void { + this.$saved.set(false); + } + + // ----- Display helpers (used by the template) ----- + + public isContainer(node: BuilderNode): boolean { + return ( + node.kind === 'group' || + (node.kind === 'catalog' && (node.itemId === 'admin' || node.itemId === 'development')) + ); + } + + public isRequired(node: BuilderNode): boolean { + return node.kind === 'catalog' && !!getMenuCatalogEntry(node.itemId)?.required; + } + + public hasRuntimeSubmenu(node: BuilderNode): boolean { + return node.kind === 'catalog' && (node.itemId === 'cases' || node.itemId === 'objects'); + } + + public displayTitle(node: BuilderNode): string { + if (node.kind === 'catalog') { + return this.translateService.instant( + node.title ?? getMenuCatalogEntry(node.itemId)?.defaultTitleKey ?? node.itemId + ); + } + return node.title; + } + + public childrenOf(node: BuilderNode): BuilderNode[] { + return this._childArray(node) ?? []; + } + + public containerListId(node: BuilderNode): string { + return `structure-children-${node.kind === 'catalog' ? node.itemId : node._uid}`; + } + + public containerEnterPredicate(node: BuilderNode): (drag: CdkDrag, drop: CdkDropList) => boolean { + if (node.kind === 'group') return this.groupEnterPredicate; + if (node.kind === 'catalog' && node.itemId === 'development') + return this.developmentEnterPredicate; + return this.adminEnterPredicate; + } + + public iconClassOf(node: BuilderNode): string | null { + return this._displayIconClass(node); + } + + public routeOf(node: BuilderNode): string { + return this._displayRoute(node); + } + + public trackByUid = (_: number, node: BuilderNode): string => node._uid; + + // ----- Private helpers ----- + + private _displayIconClass(node: BuilderNode): string | null { + if (node.kind === 'catalog') + return node.icon ?? getMenuCatalogEntry(node.itemId)?.defaultIcon ?? null; + if (node.kind === 'custom-link' || node.kind === 'plugin-page' || node.kind === 'group') { + return node.icon ?? null; + } + return null; + } + + private _displayRoute(node: BuilderNode): string { + switch (node.kind) { + case 'catalog': + return getMenuCatalogEntry(node.itemId)?.link ?? ''; + case 'custom-link': + return node.link; + case 'plugin-page': + return `/plugin-pages/${node.configurationId}${node.bundleKey ? `/${node.bundleKey}` : ''}`; + default: + return ''; + } + } + + private _editableTitle(node: BuilderNode): string { + if (node.kind === 'catalog') + return node.title ?? getMenuCatalogEntry(node.itemId)?.defaultTitleKey ?? ''; + return node.title; + } + + private _placementOf(data: PaletteItem | BuilderNode): MenuItemPlacement { + if (data && 'paletteType' in data) { + if (data.paletteType === 'catalog' && data.itemId) { + return getMenuCatalogEntry(data.itemId)?.placement ?? 'any'; + } + // A section (group) is top-level only; everything else generic is freely placeable. + return data.paletteType === 'group' ? 'top' : 'any'; + } + if (data && 'kind' in data && data.kind === 'catalog') { + return getMenuCatalogEntry(data.itemId)?.placement ?? 'any'; + } + return data && 'kind' in data && data.kind === 'group' ? 'top' : 'any'; + } + + private _containerAccepts( + placement: MenuItemPlacement, + data: PaletteItem | BuilderNode + ): boolean { + const itemPlacement = this._placementOf(data); + return itemPlacement === 'any' || itemPlacement === placement; + } + + private _paletteItemToNode(item: PaletteItem): BuilderNode | null { + switch (item.paletteType) { + case 'catalog': + return item.itemId ? this._withUid({kind: 'catalog', itemId: item.itemId}) : null; + case 'group': + return this._withUid({ + kind: 'group', + title: this.translateService.instant('adminSettings.menuConfiguration.defaults.group'), + children: [], + }); + case 'section-header': + return this._withUid({ + kind: 'section-header', + title: this.translateService.instant( + 'adminSettings.menuConfiguration.defaults.sectionHeader' + ), + }); + case 'custom-link': + return this._withUid({ + kind: 'custom-link', + title: this.translateService.instant( + 'adminSettings.menuConfiguration.defaults.customLink' + ), + link: '/', + }); + case 'plugin-page': + return item.page + ? this._withUid({ + kind: 'plugin-page', + configurationId: item.page.configurationId, + bundleKey: item.page.bundleKey ?? undefined, + title: item.label, + icon: item.page.icon ?? undefined, + }) + : null; + default: + return null; + } + } + + private _defaultTargetFor(item: PaletteItem): BuilderNode[] { + // Drag is unconstrained for these items, but the Add button still drops a page into its natural + // home section by default (its catalog category), falling back to the top level. + if (item.paletteType === 'catalog' && item.itemId) { + const category = getMenuCatalogEntry(item.itemId)?.category; + if (category === 'admin') return this._targetForSection('admin'); + if (category === 'development') return this._targetForSection('development'); + } + return this.$structure(); + } + + /** A container's stable section id: catalog `itemId` for Admin/Development, `_uid` for a custom section. */ + private _sectionValue(node: BuilderNode): string { + return node.kind === 'catalog' ? node.itemId : node._uid; + } + + /** The array that backs a section id (`'root'`, a catalog `itemId`, or a group `_uid`), creating children if needed. */ + private _targetForSection(section: string): BuilderNode[] { + if (!section || section === 'root') return this.$structure(); + const container = this.$structure().find( + node => this.isContainer(node) && this._sectionValue(node) === section + ); + if (container && (container.kind === 'catalog' || container.kind === 'group')) { + container.children = (container.children as BuilderNode[]) ?? []; + return container.children as BuilderNode[]; + } + return this.$structure(); + } + + /** Which section a node currently lives in: `'root'` or the containing section's id. */ + private _findSectionOf(uid: string): string { + if (this.$structure().some(node => node._uid === uid)) return 'root'; + for (const node of this.$structure()) { + const children = this._childArray(node); + if (children && children.some(child => child._uid === uid)) { + return this._sectionValue(node); + } + } + return 'root'; + } + + private _buildPaletteGroups(): PaletteGroup[] { + const placedCatalog = new Set(); + const placedPlugins = new Set(); + this._walk(this.$structure(), node => { + if (node.kind === 'catalog') placedCatalog.add(node.itemId); + if (node.kind === 'plugin-page') + placedPlugins.add(this._pluginKey(node.configurationId, node.bundleKey)); + }); + + const fromCatalog = (category: string): PaletteItem[] => + MENU_ITEM_CATALOG.filter( + entry => entry.category === category && !placedCatalog.has(entry.itemId) + ).map(entry => ({ + paletteType: 'catalog', + itemId: entry.itemId, + label: this.translateService.instant(entry.defaultTitleKey), + icon: entry.defaultIcon, + })); + + const pluginPages: PaletteItem[] = this.$pluginPages() + .filter(page => !placedPlugins.has(this._pluginKey(page.configurationId, page.bundleKey))) + .map(page => ({ + paletteType: 'plugin-page', + page, + label: this._localizedPluginTitle(page), + icon: page.icon ?? undefined, + })); + + return [ + { + categoryKey: 'generic', + items: [ + { + paletteType: 'group', + label: this.translateService.instant('adminSettings.menuConfiguration.generic.group'), + }, + { + paletteType: 'section-header', + label: this.translateService.instant( + 'adminSettings.menuConfiguration.generic.sectionHeader' + ), + }, + { + paletteType: 'custom-link', + label: this.translateService.instant( + 'adminSettings.menuConfiguration.generic.customLink' + ), + }, + ], + }, + {categoryKey: 'main', items: fromCatalog('main')}, + {categoryKey: 'admin', items: fromCatalog('admin')}, + {categoryKey: 'development', items: fromCatalog('development')}, + {categoryKey: 'pluginPages', items: pluginPages}, + ]; + } + + private _localizedPluginTitle(page: ExternalPluginMenuPage): string { + const lang = this.translateService.currentLang ?? this.translateService.defaultLang ?? 'en'; + return page.titleTranslations?.[lang] ?? page.title ?? page.configurationTitle; + } + + private _pluginKey(configurationId: string, bundleKey: string | null | undefined): string { + return `${configurationId}:${bundleKey ?? ''}`; + } + + /** + * The MDI icon selector works with the full MDI class token (e.g. `mdi-view-dashboard`) — its + * preview binds the value straight onto an element class and `POPULAR_MDI_ICONS` is `mdi-*` keyed + * — so we keep the `mdi-` prefix here (stripping it broke the preview and double-prefixed on save). + */ + private _mdiKeyFromIconClass(iconClass: string | null): string | null { + if (!iconClass) return null; + const match = iconClass.match(/(mdi-[a-z0-9-]+)/i); + return match ? match[1] : null; + } + + /** Builds the menu `iconClass` from the selector's MDI class token (`mdi-view-dashboard` → `icon mdi mdi-view-dashboard`). */ + private _iconClassFromMdiKey(key: string | null | undefined): string | undefined { + return key ? `icon mdi ${key}` : undefined; + } + + // ----- Tree utilities ----- + + /** The editable children array of a branch node (catalog group or custom section), else null. */ + private _childArray(node: BuilderNode): BuilderNode[] | null { + if ((node.kind === 'catalog' || node.kind === 'group') && Array.isArray(node.children)) { + return node.children as BuilderNode[]; + } + return null; + } + + private _withUid(node: MenuConfigurationItem): BuilderNode { + return {...node, _uid: `node-${this._uidCounter++}`} as BuilderNode; + } + + private _assignUids(items: MenuConfigurationItem[]): BuilderNode[] { + return items.map(item => { + const node = this._withUid(item); + const children = this._childArray(node); + if (children) { + (node as {children: BuilderNode[]}).children = this._assignUids(children); + } + return node; + }); + } + + private _walk(nodes: BuilderNode[], visit: (node: BuilderNode) => void): void { + nodes.forEach(node => { + visit(node); + const children = this._childArray(node); + if (children) this._walk(children, visit); + }); + } + + private _findByUid(nodes: BuilderNode[], uid: string): BuilderNode | null { + for (const node of nodes) { + if (node._uid === uid) return node; + const children = this._childArray(node); + if (children) { + const found = this._findByUid(children, uid); + if (found) return found; + } + } + return null; + } + + private _removeByUid(nodes: BuilderNode[], uid: string): boolean { + const index = nodes.findIndex(node => node._uid === uid); + if (index >= 0) { + nodes.splice(index, 1); + return true; + } + return nodes.some(node => { + const children = this._childArray(node); + return !!children && this._removeByUid(children, uid); + }); + } + + /** Deep-clones array references so the OnPush drag-drop lists re-render after an in-place mutation. */ + private _bump(): void { + this.$saved.set(false); + this.$structure.set(this._clone(this.$structure())); + } + + private _clone(nodes: BuilderNode[]): BuilderNode[] { + return nodes.map(node => { + const copy = {...node} as BuilderNode; + const children = this._childArray(node); + if (children) (copy as {children: BuilderNode[]}).children = this._clone(children); + return copy; + }); + } + + /** Produces the persisted structure without the transient `_uid` fields. */ + private _stripUids(nodes: BuilderNode[]): MenuConfigurationItem[] { + return nodes.map(node => { + const {_uid, ...rest} = node as BuilderNode & {_uid: string}; + const clean = rest as MenuConfigurationItem; + if ((clean.kind === 'catalog' || clean.kind === 'group') && Array.isArray(clean.children)) { + clean.children = this._stripUids(clean.children as BuilderNode[]); + } + return clean; + }); + } +} diff --git a/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings/admin-settings.component.html b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings/admin-settings.component.html index 3988fabadf..a207230579 100644 --- a/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings/admin-settings.component.html +++ b/frontend/projects/valtimo/admin-settings/src/lib/components/admin-settings/admin-settings.component.html @@ -30,6 +30,18 @@ } + @if ({active: activeTabKey === ADMIN_SETTINGS_TABS.MENU_CONFIGURATION}; as obs) { + + @if (obs.active) { + + } + + } + @if ({active: activeTabKey === ADMIN_SETTINGS_TABS.FEATURE_TOGGLES}; as obs) { { try { const adminSettingsService = injector.get(AdminSettingsService); @@ -78,7 +89,7 @@ export function initializerFactory( // Fetch accent colors from the backend and apply them as CSS custom properties // before other initializers run, so the UI renders with the correct colors immediately. - initializersArray.push(async () => { + const accentColorsInitializer = async (): Promise => { try { const adminSettingsService = injector.get(AdminSettingsService); const colors = await firstValueFrom(adminSettingsService.getAccentColors()); @@ -89,10 +100,57 @@ export function initializerFactory( } catch (error) { logger.warn('Failed to fetch accent colors, using defaults', error); } - }); + }; + + // Initialize CSP after auth so we can fetch external plugin host origins and add them to + // frame-src before the meta tag is inserted (CSP meta is immutable once parsed). + const cspInitializer = async (): Promise => { + const pluginHostOrigins = new Set(); + const collectOrigin = (value: string | null | undefined): void => { + if (!value) return; + try { + pluginHostOrigins.add(new URL(value).origin); + } catch { + // ignore unparseable URLs + } + }; + + const http = injector.get(HttpClient); + const apiBase = configService.config?.valtimoApi?.endpointUri; + + if (apiBase) { + // host-origins is authenticated (not ADMIN-only) so every user who renders a plugin surface + // gets the host origins into frame-src/connect-src; it exposes derived origins only. + await firstValueFrom(http.get(`${apiBase}v1/external-plugin/host-origins`)) + .then(origins => origins.forEach(origin => collectOrigin(origin))) + .catch(error => + logger.debug('No external plugin host origins found for CSP augmentation:', error) + ); + } + + await initializeCsp(logger, configService, document, domSanitizer, [...pluginHostOrigins])(); + }; + + // Fetch the persisted menu configuration and, when one exists, resolve it into MenuItem[] and + // patch config.menu.menuItems BEFORE the menu initializer runs (mirrors the feature-toggle/accent + // patches above). When the DB has no saved config (every existing installation) or the call + // errors, do nothing — config.menu is left untouched so the static environment.ts menu (custom + // links included) renders byte-identically. Backwards compatible by construction. + const menuConfigurationInitializer = async (): Promise => { + try { + const adminSettingsService = injector.get(AdminSettingsService); + const dto = await firstValueFrom(adminSettingsService.getMenuConfiguration()); + if (hasSavedMenuConfiguration(dto)) { + configService.config.menu.menuItems = resolveMenuConfiguration(dto.configuration, logger); + logger.debug('Persisted menu configuration applied'); + } + } catch (error) { + logger.warn('Failed to fetch menu configuration, using default menu', error); + } + }; // Check OpenSearch availability and patch feature toggle - initializersArray.push(async () => { + const openSearchInitializer = async (): Promise => { try { const httpClient = injector.get(HttpClient); const response = await firstValueFrom( @@ -106,7 +164,21 @@ export function initializerFactory( } catch { // OpenSearch not available } - }); + }; + + // These four initializers are order-independent (accent colors touch CSS custom properties, CSP + // inserts the meta tag, the menu patch only reads the DTO, and the OpenSearch check only patches + // its own toggle), so run them in parallel instead of serially blocking bootstrap on each round + // trip. Each one handles its own failures, so this combined initializer cannot reject for + // recoverable reasons. + initializersArray.push(() => + Promise.all([ + accentColorsInitializer(), + cspInitializer(), + menuConfigurationInitializer(), + openSearchInitializer(), + ]) + ); // Use environment config initializers to be used in app startup. configService.initializers.forEach(initializer => { diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts index d9230a5f80..82ae775f0c 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts @@ -73,10 +73,14 @@ export class CaseManagementDetailComponent implements OnInit, OnDestroy { map(params => params?.caseDefinitionKey ?? '') ); - public readonly caseListColumn$ = - this.configService.getFeatureToggleObservable('caseListColumn', true); - public readonly tabManagementEnabled$ = - this.configService.getFeatureToggleObservable('enableTabManagement', true); + public readonly caseListColumn$ = this.configService.getFeatureToggleObservable( + 'caseListColumn', + true + ); + public readonly tabManagementEnabled$ = this.configService.getFeatureToggleObservable( + 'enableTabManagement', + true + ); public _activeTab: TabEnum | string; public pendingTab: TabEnum | null | string; @@ -103,7 +107,12 @@ export class CaseManagementDetailComponent implements OnInit, OnDestroy { private readonly _refreshConfigurationIssues$ = new BehaviorSubject(null); public readonly hasPluginProcessLinkIssue$: Observable = - this.configurationIssueService.hasIssue$('plugin-process-link'); + this.configurationIssueService.hasAnyOfIssues$([ + 'plugin-process-link', + 'external-plugin-process-link', + 'external-plugin-task-form', + 'external-plugin-case-tab', + ]); private readonly _tabIssueCache = new Map>(); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html index 1457aedcaf..4bf43a2700 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html @@ -44,12 +44,27 @@

@if (row.status === 'available') { -
- +
+
+ +
+ + @if (selectedConfigurationVersion(row, i); as mismatchedVersion) { +
+ {{ + 'caseManagement.missingPluginConfigurations.externalVersionMismatchWarning' + | translate + : { + selectedVersion: mismatchedVersion, + requiredVersion: row.pluginDefinitionVersion + } + }} +
+ }
} @else if (row.status === 'not-installed') {
diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss index eeffaa3fb1..50efea0d70 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss @@ -69,6 +69,18 @@ width: 100%; } + &__target { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-02); + } + + &__version-warning { + font-size: 0.75rem; + color: var(--cds-support-warning); + padding: 0 1rem; + } + &__unavailable { font-size: 0.875rem; color: var(--cds-text-disabled); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts index becb5a339a..58803dea64 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts @@ -20,7 +20,7 @@ import {CommonModule} from '@angular/common'; import {ActivatedRoute} from '@angular/router'; import {TranslateModule, TranslateService} from '@ngx-translate/core'; import {ArrowRight16, Save16} from '@carbon/icons'; -import {SelectModule} from '@valtimo/components'; +import {SelectItem, SelectModule} from '@valtimo/components'; import { ButtonModule, IconModule, @@ -29,6 +29,10 @@ import { NotificationModule, } from 'carbon-components-angular'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -39,14 +43,32 @@ import { getCaseManagementRouteParams, GlobalNotificationService, } from '@valtimo/shared'; -import {BehaviorSubject, combineLatest, filter, map, Observable, switchMap, take} from 'rxjs'; +import { + BehaviorSubject, + catchError, + combineLatest, + filter, + forkJoin, + map, + Observable, + of, + switchMap, + take, +} from 'rxjs'; import {CaseManagementService} from '../../../../../../services'; import { DanglingPluginConfiguration, MappingRow, + PluginConfigurationPreviewSource, PluginMappingStatus, } from '../../../../../../models/case-deployment.model'; +const EMBEDDED_ISSUE_TYPE = 'plugin-process-link'; +const EXTERNAL_ISSUE_TYPE = 'external-plugin-process-link'; +const EXTERNAL_TASK_FORM_ISSUE_TYPE = 'external-plugin-task-form'; +const EXTERNAL_CASE_TAB_ISSUE_TYPE = 'external-plugin-case-tab'; +const EXTERNAL_CASE_WIDGET_ISSUE_TYPE = 'external-plugin-case-widget'; + @Component({ selector: 'valtimo-case-management-missing-plugin-configurations', templateUrl: './case-management-missing-plugin-configurations.component.html', @@ -63,7 +85,13 @@ import { ], }) export class CaseManagementMissingPluginConfigurationsComponent implements OnInit { - public readonly hasIssue$ = this.configurationIssueService.hasIssue$('plugin-process-link'); + public readonly hasIssue$ = this.configurationIssueService.hasAnyOfIssues$([ + EMBEDDED_ISSUE_TYPE, + EXTERNAL_ISSUE_TYPE, + EXTERNAL_TASK_FORM_ISSUE_TYPE, + EXTERNAL_CASE_TAB_ISSUE_TYPE, + EXTERNAL_CASE_WIDGET_ISSUE_TYPE, + ]); public readonly mappingRows$ = new BehaviorSubject([]); public readonly hasUnknownPluginConfigurations$ = new BehaviorSubject(false); public readonly visible$ = combineLatest([ @@ -87,6 +115,7 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni constructor( private readonly caseManagementService: CaseManagementService, private readonly configurationIssueService: ConfigurationIssueService, + private readonly externalPluginService: ExternalPluginService, private readonly globalNotificationService: GlobalNotificationService, private readonly iconService: IconService, private readonly pluginManagementService: PluginManagementService, @@ -121,6 +150,22 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni this._selections.set(index, selectedId ? String(selectedId) : null); } + /** + * The actual definition version of the currently selected external configuration for the row at + * [index], `null` when the selection is an exact `pluginId@version` match, embedded, or the row + * has no selection yet (D3 non-blocking warning). + */ + public selectedConfigurationVersion(row: MappingRow, index: number): string | null { + if (!row.mismatchedVersionsById || row.mismatchedVersionsById.size === 0) { + return null; + } + const selectedId = this._selections.get(index); + if (!selectedId) { + return null; + } + return row.mismatchedVersionsById.get(selectedId) ?? null; + } + public save(): void { const mappings: Record = {}; const rows = this.mappingRows$.value; @@ -182,80 +227,179 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni return; } - this.pluginManagementService - .getPluginDefinitions() + const embeddedDangling = knownKeyDangling.filter(d => (d.source ?? 'embedded') === 'embedded'); + const externalDangling = knownKeyDangling.filter(d => d.source === 'external'); + + combineLatest([ + this.loadEmbeddedRows(embeddedDangling), + this.loadExternalRows(externalDangling), + ]) .pipe(take(1)) - .subscribe(definitions => { - const installedKeys = new Set(definitions.map(d => d.key)); - this.loadPluginConfigurations(knownKeyDangling, installedKeys); + .subscribe(([embeddedRows, externalRows]) => { + this._selections.clear(); + this.mappingRows$.next([...embeddedRows, ...externalRows]); }); } + private loadEmbeddedRows(dangling: DanglingPluginConfiguration[]): Observable { + if (dangling.length === 0) { + return of([]); + } + + return this.pluginManagementService.getPluginDefinitions().pipe( + take(1), + switchMap(definitions => { + const installedKeys = new Set(definitions.map(d => d.key)); + return this.loadPluginConfigurations(dangling, installedKeys); + }) + ); + } + private loadPluginConfigurations( dangling: DanglingPluginConfiguration[], installedKeys: Set - ): void { + ): Observable { const uniqueKeys = [...new Set(dangling.map(d => d.pluginDefinitionKey).filter(Boolean))]; const installableKeys = uniqueKeys.filter(k => installedKeys.has(k)); - const configsByKey = new Map(); - let remaining = installableKeys.length; - if (remaining === 0) { - this.buildRows(dangling, configsByKey, installedKeys); - return; + if (installableKeys.length === 0) { + return of(this.buildEmbeddedRows(dangling, new Map(), installedKeys)); } + const configRequests: Record> = {}; for (const key of installableKeys) { - this.pluginManagementService + configRequests[key] = this.pluginManagementService .getPluginConfigurationsByPluginDefinitionKey(key) - .pipe(take(1)) - .subscribe({ - next: configs => { - configsByKey.set(key, configs); - remaining--; - if (remaining === 0) this.buildRows(dangling, configsByKey, installedKeys); - }, - error: () => { - configsByKey.set(key, []); - remaining--; - if (remaining === 0) this.buildRows(dangling, configsByKey, installedKeys); - }, - }); + .pipe( + take(1), + catchError(() => of([] as PluginConfiguration[])) + ); } + + return forkJoin(configRequests).pipe( + take(1), + map(results => { + const configsByKey = new Map(Object.entries(results)); + return this.buildEmbeddedRows(dangling, configsByKey, installedKeys); + }) + ); } - private buildRows( + private buildEmbeddedRows( dangling: DanglingPluginConfiguration[], configsByKey: Map, installedKeys: Set - ): void { - this._selections.clear(); - this.mappingRows$.next( - dangling.map(d => { - const key = d.pluginDefinitionKey; - const isInstalled = key ? installedKeys.has(key) : false; - const available = configsByKey.get(key) || []; - - let status: PluginMappingStatus; - if (!isInstalled) { - status = 'not-installed'; - } else if (available.length === 0) { - status = 'no-configurations'; - } else { - status = 'available'; - } + ): MappingRow[] { + return dangling.map(d => { + const key = d.pluginDefinitionKey; + const isInstalled = key ? installedKeys.has(key) : false; + const available = configsByKey.get(key) || []; + + const status = this.determineStatus(isInstalled, available.length > 0); + + return { + pluginDefinitionKey: key, + pluginDefinitionTitle: this.getPluginTitle(key), + sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, + selectItems: available.map(c => ({id: c.id, text: c.title})), + status, + source: 'embedded' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: null, + }; + }); + } + + private loadExternalRows(dangling: DanglingPluginConfiguration[]): Observable { + if (dangling.length === 0) { + return of([]); + } + + return combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + take(1), + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const lang = this.translateService.currentLang; + + return dangling.map(d => { + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === d.pluginDefinitionKey; + }); - return { - pluginDefinitionKey: key, - pluginDefinitionTitle: this.getPluginTitle(key), - sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, - selectItems: available.map(c => ({id: c.id, text: c.title})), - status, - }; + const isInstalled = [...definitionById.values()].some( + def => def.pluginId === d.pluginDefinitionKey + ); + const status = this.determineStatus(isInstalled, matchingConfigurations.length > 0); + + const mismatchedVersionsById = new Map(); + const selectItems: SelectItem[] = matchingConfigurations.map(configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== d.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + id: configuration.id, + text: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + }; + }); + + return { + pluginDefinitionKey: d.pluginDefinitionKey, + pluginDefinitionTitle: this.getExternalPluginTitle(d, definitionById), + sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, + selectItems, + status, + source: 'external' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: d.pluginDefinitionVersion ?? null, + mismatchedVersionsById, + }; + }); }) ); } + private getExternalPluginTitle( + dangling: DanglingPluginConfiguration, + definitionById: Map + ): string { + if (!dangling.pluginDefinitionKey) { + return this.translateService.instant( + 'caseManagement.missingPluginConfigurations.unknownPlugin' + ); + } + const lang = this.translateService.currentLang; + const matchingDefinition = [...definitionById.values()].find( + d => + d.pluginId === dangling.pluginDefinitionKey && + d.version === dangling.pluginDefinitionVersion + ); + if (matchingDefinition) { + return getExternalPluginDisplayName(matchingDefinition, lang); + } + return dangling.pluginDefinitionVersion + ? `${dangling.pluginDefinitionKey} (${dangling.pluginDefinitionVersion})` + : dangling.pluginDefinitionKey; + } + + private determineStatus(isInstalled: boolean, hasConfigurations: boolean): PluginMappingStatus { + if (!isInstalled) { + return 'not-installed'; + } + if (!hasConfigurations) { + return 'no-configurations'; + } + return 'available'; + } + private getPluginTitle(pluginDefinitionKey: string | null): string { if (!pluginDefinitionKey) { return this.translateService.instant( diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-add-tab-modal/case-management-add-tab-modal.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-add-tab-modal/case-management-add-tab-modal.component.html index e1c4993d3d..9864daa99d 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-add-tab-modal/case-management-add-tab-modal.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-add-tab-modal/case-management-add-tab-modal.component.html @@ -38,6 +38,7 @@

- {{ - isTranslated('case.tabs.' + data.item.contentKey) - ? ('case.tabs.' + data.item.contentKey | translate) - : data.item.contentKey - }} + {{ getTabContentLabel(data.item) }} diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-tabs.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-tabs.component.ts index f4c8175bcb..ea2faa7759 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-tabs.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/case-management-tabs.component.ts @@ -90,6 +90,9 @@ export class CaseManagementTabsComponent implements AfterViewInit { public readonly tab$ = new BehaviorSubject(null); public readonly dragAndDropDisabled = signal(false); + // contentKey → human label for EXTERNAL_PLUGIN tabs (the raw contentKey is a config UUID). + private _externalPluginContentLabels: Record = {}; + private readonly params$: Observable = getCaseManagementRouteParams(this.route); @@ -117,12 +120,36 @@ export class CaseManagementTabsComponent implements AfterViewInit { this.iconService.registerAll([ArrowDown16, ArrowUp16]); this.cd.detectChanges(); this.setFields(); + this.loadExternalPluginContentLabels(); } public isTranslated(key: string): boolean { return this.translateService.instant(key) !== key; } + /** + * Label shown in the content column. For an external-plugin tab the raw `contentKey` is a + * configuration UUID, so it is resolved to "configTitle (pluginName) — bundleTitle"; other tab + * types keep the `case.tabs.` translation (falling back to the raw key). + */ + public getTabContentLabel(item: ApiTabItem): string { + if (item.type === ApiTabType.EXTERNAL_PLUGIN) { + return this._externalPluginContentLabels[item.contentKey] ?? item.contentKey; + } + const key = `case.tabs.${item.contentKey}`; + return this.isTranslated(key) ? this.translateService.instant(key) : item.contentKey; + } + + private loadExternalPluginContentLabels(): void { + this.tabService.getExternalPluginTabItems().subscribe(items => { + this._externalPluginContentLabels = items.reduce>((labels, item) => { + labels[item.contentKey] = item.content; + return labels; + }, {}); + this.cd.markForCheck(); + }); + } + public openAddTabModal(): void { this.openAddModal$.next(true); } diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/tab-form/tab-form.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/tab-form/tab-form.component.html index b4de93c173..7f60bbb4e6 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/tab-form/tab-form.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/tab-form/tab-form.component.html @@ -33,19 +33,46 @@ /> - - - + + + + + + + + + + + + + + + { + map(([tabKeys, formDefinitions, defaultTabs, customComponentKeys, externalPluginItems]) => { switch (this.tabType) { case ApiTabType.STANDARD: return this.getListItems(defaultTabs, tabKeys); @@ -48,8 +58,12 @@ export class TabFormComponent implements OnInit, OnDestroy { return this.getListItems(customComponentKeys, tabKeys); case ApiTabType.FORMIO: return this.getListItems(formDefinitions, tabKeys); + case ApiTabType.EXTERNAL_PLUGIN: + return this.getListItems(externalPluginItems, tabKeys); case ApiTabType.WIDGETS: return []; + default: + return []; } }), startWith([]) @@ -59,6 +73,45 @@ export class TabFormComponent implements OnInit, OnDestroy { public showTasks!: AbstractControl; + // External-plugin tabs are configured with two dropdowns: configuration, then tab (bundle). + public readonly TabType = ApiTabType; + private readonly _externalPluginConfigs$ = this.tabService + .getExternalPluginConfigs() + .pipe(shareReplay({bufferSize: 1, refCount: true})); + private readonly _selectedConfigId$ = new BehaviorSubject(null); + private readonly _selectedBundleKey$ = new BehaviorSubject(null); + private _configs: ExternalPluginTabConfigOption[] = []; + + public readonly selectedConfigId$ = this._selectedConfigId$.asObservable(); + + public readonly configItems$: Observable = combineLatest([ + this._externalPluginConfigs$, + this._selectedConfigId$, + ]).pipe( + map(([configs, selectedConfigId]) => + configs.map(config => ({ + content: config.label, + configId: config.configId, + selected: config.configId === selectedConfigId, + })) + ) + ); + + public readonly bundleItems$: Observable = combineLatest([ + this._externalPluginConfigs$, + this._selectedConfigId$, + this._selectedBundleKey$, + ]).pipe( + map(([configs, selectedConfigId, selectedBundleKey]) => { + const config = configs.find(item => item.configId === selectedConfigId); + return (config?.bundles ?? []).map(bundle => ({ + content: bundle.title, + bundleKey: bundle.key, + selected: bundle.key === selectedBundleKey, + })); + }) + ); + private _searchActive: boolean; private _subscriptions = new Subscription(); @@ -80,6 +133,13 @@ export class TabFormComponent implements OnInit, OnDestroy { } else { this.form.get('contentKey')?.enable(); } + + if (this.tabType === ApiTabType.EXTERNAL_PLUGIN) { + this._subscriptions.add( + this._externalPluginConfigs$.subscribe(configs => (this._configs = configs)) + ); + this.preselectExternalPlugin(); + } } public ngOnDestroy(): void { @@ -111,6 +171,45 @@ export class TabFormComponent implements OnInit, OnDestroy { this._searchActive = false; } + public onConfigSelected(item: ListItem & {configId?: string}): void { + const configId = item?.configId ?? null; + this._selectedConfigId$.next(configId); + this._selectedBundleKey$.next(null); + + const config = this._configs.find(candidate => candidate.configId === configId); + // A configuration with a single bundle needs no second choice — resolve the contentKey now. + if (config && config.bundles.length === 1) { + const bundle = config.bundles[0]; + this._selectedBundleKey$.next(bundle.key); + this.setExternalPluginContentKey(configId, bundle.key); + } else { + // Multiple bundles: clear the contentKey so the form stays invalid until a tab is picked. + this.form.get('contentKey')?.setValue(''); + } + } + + public onBundleSelected(item: ListItem & {bundleKey?: string | null}): void { + const bundleKey = item?.bundleKey ?? null; + this._selectedBundleKey$.next(bundleKey); + this.setExternalPluginContentKey(this._selectedConfigId$.value, bundleKey); + } + + private setExternalPluginContentKey(configId: string | null, bundleKey: string | null): void { + if (!configId) return; + const contentKey = bundleKey ? `${configId}:${bundleKey}` : configId; + this.form.get('contentKey')?.setValue(contentKey); + } + + private preselectExternalPlugin(): void { + const contentKey = this.form.get('contentKey')?.value as string | undefined; + if (!contentKey) return; + const separatorIndex = contentKey.indexOf(':'); + const configId = separatorIndex >= 0 ? contentKey.substring(0, separatorIndex) : contentKey; + const bundleKey = separatorIndex >= 0 ? contentKey.substring(separatorIndex + 1) : null; + this._selectedConfigId$.next(configId); + this._selectedBundleKey$.next(bundleKey); + } + public toggleCheckedChange(event: boolean): void { this.showTasks?.patchValue(!!event); } diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html index 995cf40265..56d12ebce0 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html @@ -15,7 +15,7 @@ --> diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts index eecffab5f4..069c1485f6 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts @@ -33,6 +33,8 @@ import { RenderInPageHeaderDirective, } from '@valtimo/components'; import { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetConfigProvider, IWidgetManagementService, ManagementWidgetDetailsComponent, WIDGET_MANAGEMENT_SERVICE, @@ -43,9 +45,24 @@ import { import {CaseManagementParams, getCaseManagementRouteParams} from '@valtimo/shared'; import {ButtonModule, IconModule, IconService, TabsModule} from 'carbon-components-angular'; import moment from 'moment/moment'; -import {BehaviorSubject, combineLatest, filter, map, Observable, switchMap, tap} from 'rxjs'; +import { + BehaviorSubject, + catchError, + combineLatest, + filter, + map, + Observable, + of, + startWith, + switchMap, + tap, +} from 'rxjs'; -import {TabManagementService, CaseWidgetManagementApiService} from '../../../../../../services'; +import { + CaseWidgetManagementApiService, + TabManagementService, + TabService, +} from '../../../../../../services'; import {CaseManagementWidgetTabEditModalComponent} from '../case-management-widget-tab-edit-modal/case-management-widget-tab-edit-modal.component'; @Component({ @@ -67,6 +84,13 @@ import {CaseManagementWidgetTabEditModalComponent} from '../case-management-widg provide: WIDGET_MANAGEMENT_SERVICE, useClass: CaseWidgetManagementApiService, }, + { + provide: EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + useFactory: (tabService: TabService): ExternalPluginWidgetConfigProvider => ({ + getConfigOptions: () => tabService.getExternalPluginWidgetConfigs(), + }), + deps: [TabService], + }, ], }) export class CaseManagementWidgetTabComponent @@ -133,7 +157,8 @@ export class CaseManagementWidgetTabComponent ); public readonly compactMode$ = this.pageHeaderService.compactMode$; - public readonly AVAILABLE_WIDGET_TYPES = [ + + private readonly _baseWidgetTypes = [ WidgetType.FIELDS, WidgetType.COLLECTION, WidgetType.CUSTOM, @@ -147,6 +172,23 @@ export class CaseManagementWidgetTabComponent WidgetType.TEXT, ]; + /** + * The selectable widget types. `external-plugin` is only offered when at least one activated + * plugin configuration exposes a `case-widget` bundle. Emits the base list synchronously (never + * `null`) so the wizard never shows the type before the availability check resolves. + */ + public readonly availableWidgetTypes$: Observable = this.tabService + .getExternalPluginWidgetConfigs() + .pipe( + map(configs => + configs.length > 0 + ? [...this._baseWidgetTypes, WidgetType.EXTERNAL_PLUGIN] + : this._baseWidgetTypes + ), + catchError(() => of(this._baseWidgetTypes)), + startWith(this._baseWidgetTypes) + ); + constructor( protected readonly widgetWizardService: WidgetWizardService, private readonly breadcrumbService: BreadcrumbService, @@ -154,6 +196,7 @@ export class CaseManagementWidgetTabComponent private readonly pageTitleService: PageTitleService, private readonly route: ActivatedRoute, private readonly tabManagementService: TabManagementService, + private readonly tabService: TabService, @Inject(WIDGET_MANAGEMENT_SERVICE) private readonly caseWidgetManagementApiService: IWidgetManagementService< CaseManagementParams & {key: string} @@ -193,7 +236,10 @@ export class CaseManagementWidgetTabComponent } private initBreadcrumbs(): void { - this.caseManagementRouteParams$.subscribe(params => { + combineLatest([ + this.caseManagementRouteParams$, + this.translateService.stream('caseManagement.tabs.caseDetailsTab.title'), + ]).subscribe(([params, caseDetailsTitle]) => { const route = `/case-management/case/${params.caseDefinitionKey}/version/${params.caseDefinitionVersionTag}`; this.breadcrumbService.setThirdBreadcrumb({ @@ -204,7 +250,7 @@ export class CaseManagementWidgetTabComponent this.breadcrumbService.setFourthBreadcrumb({ route: [`${route}/case-details`], - content: this.translateService.instant('caseManagement.tabs.caseDetailsTab.title'), + content: caseDetailsTitle, href: `${route}/case-details`, }); }); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html index a1ce58bda5..a927a41621 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html @@ -205,20 +205,35 @@

> - - - +
+ + + + +
+ {{ + 'caseManagement.importDefinition.plugins.externalVersionMismatchWarning' + | translate + : { + selectedVersion: mismatchedVersion, + requiredVersion: row.pluginDefinitionVersion + } + }} +
+
{{ 'caseManagement.importDefinition.plugins.notInstalled' | translate }}
diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss index 4b9a95331e..f4d21033cf 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss @@ -151,6 +151,19 @@ width: 100%; } + &__plugins-target { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-02); + align-self: center; + } + + &__plugins-version-warning { + font-size: 0.75rem; + color: var(--cds-support-warning); + padding: 0 1rem; + } + &__plugins-unavailable { font-size: 0.875rem; color: var(--cds-text-disabled); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts index 3e2dd7bcf3..e0cfa8f95c 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts @@ -26,6 +26,10 @@ import {AbstractControl, FormBuilder, FormGroup, Validators} from '@angular/form import {TranslateService} from '@ngx-translate/core'; import {CARBON_CONSTANTS} from '@valtimo/components'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -33,13 +37,16 @@ import { import {FileItem, ListItem} from 'carbon-components-angular'; import { BehaviorSubject, + catchError, combineLatest, debounceTime, distinctUntilChanged, forkJoin, map, Observable, + of, Subscription, + switchMap, take, } from 'rxjs'; import { @@ -53,6 +60,7 @@ import {CASE_MANAGEMENT_UPLOAD_TEST_IDS} from '../../constants'; import { CaseDefinitionImportPreview, PluginConfigurationPreview, + PluginConfigurationPreviewSource, } from '../../models/case-deployment.model'; type PluginMappingStatus = 'available' | 'no-configurations' | 'not-installed'; @@ -64,6 +72,14 @@ interface PluginMappingRow { existsInTargetEnvironment: boolean; listItems: ListItem[]; status: PluginMappingStatus; + source: PluginConfigurationPreviewSource; + pluginDefinitionVersion: string | null; + /** + * External configuration id -> actual definition version, populated only for options whose + * version differs from `pluginDefinitionVersion` (D3 non-blocking warning). Empty for embedded + * rows and exact-version-only sets. + */ + mismatchedVersionsById: Map; } @Component({ @@ -164,7 +180,8 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private readonly fb: FormBuilder, private readonly translateService: TranslateService, private readonly pluginManagementService: PluginManagementService, - private readonly pluginTranslationService: PluginTranslationService + private readonly pluginTranslationService: PluginTranslationService, + private readonly externalPluginService: ExternalPluginService ) {} public ngOnInit(): void { @@ -235,6 +252,23 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { return row.sourcePluginConfigurationId; } + /** + * The actual definition version of the currently selected external configuration for [row], + * `null` when the selection is an exact `pluginId@version` match or the row has no selection + * (D3 non-blocking warning). Reads the live form control value so the warning stays in sync as + * the admin changes the dropdown selection. + */ + public selectedConfigurationVersion(row: PluginMappingRow): string | null { + if (row.mismatchedVersionsById.size === 0) { + return null; + } + const selectedId = this.pluginMappingForm.get(row.sourcePluginConfigurationId)?.value; + if (!selectedId) { + return null; + } + return row.mismatchedVersionsById.get(selectedId) ?? null; + } + /** * Works around a carbon-components-angular bug where clearing a single-select * cds-combo-box with itemValueKey set writes `[]` to the FormControl instead @@ -288,7 +322,11 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private loadPluginMappingRows(pluginConfigs: PluginConfigurationPreview[]): void { const uniqueById = new Map(); for (const config of pluginConfigs) { - if (!uniqueById.has(config.pluginConfigurationId)) { + // One row per configuration id, but never let a key-less contribution (e.g. an external + // plugin case tab) shadow a mappable one (a process link) for the same configuration — + // key-less rows are filtered out below, which would hide the mapping choice entirely. + const existing = uniqueById.get(config.pluginConfigurationId); + if (!existing || (existing.pluginDefinitionKey === null && config.pluginDefinitionKey !== null)) { uniqueById.set(config.pluginConfigurationId, config); } } @@ -309,53 +347,72 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { return; } - // Fetch all installed plugin definitions first, then configs per key - this.pluginManagementService - .getPluginDefinitions() + const embeddedConfigs = uniqueConfigs.filter(c => (c.source ?? 'embedded') === 'embedded'); + const externalConfigs = uniqueConfigs.filter(c => c.source === 'external'); + + combineLatest([ + this.loadEmbeddedMappingRows(embeddedConfigs), + this.loadExternalMappingRows(externalConfigs), + ]) .pipe(take(1)) - .subscribe(definitions => { - const installedKeys = new Set(definitions.map(d => d.key)); - this.loadPluginConfigurations(uniqueConfigs, installedKeys); + .subscribe(([embeddedRows, externalRows]) => { + this.clearPluginMappingForm(); + const rows = [...embeddedRows, ...externalRows]; + for (const row of rows) { + if (row.status === 'available') { + this.pluginMappingForm.addControl( + row.sourcePluginConfigurationId, + this.fb.control(row.listItems.find(item => item.selected)?.id ?? null) + ); + } + } + this.pluginMappingRows$.next(rows); }); } - private loadPluginConfigurations( - uniqueConfigs: PluginConfigurationPreview[], - installedKeys: Set - ): void { - const uniqueDefinitionKeys = [ - ...new Set(uniqueConfigs.map(c => c.pluginDefinitionKey).filter(Boolean)), - ]; + private loadEmbeddedMappingRows( + uniqueConfigs: PluginConfigurationPreview[] + ): Observable { + if (uniqueConfigs.length === 0) { + return of([]); + } - const installableKeys = uniqueDefinitionKeys.filter(k => installedKeys.has(k)); + return this.pluginManagementService.getPluginDefinitions().pipe( + take(1), + switchMap(definitions => { + const installedKeys = new Set(definitions.map(d => d.key)); + const installableKeys = [ + ...new Set(uniqueConfigs.map(c => c.pluginDefinitionKey).filter(Boolean)), + ].filter(key => installedKeys.has(key)); - if (installableKeys.length === 0) { - this.buildMappingRows(uniqueConfigs, new Map(), installedKeys); - return; - } + if (installableKeys.length === 0) { + return of(this.buildEmbeddedRows(uniqueConfigs, new Map(), installedKeys)); + } - const configRequests: Record> = {}; - for (const key of installableKeys) { - configRequests[key] = this.pluginManagementService - .getPluginConfigurationsByPluginDefinitionKey(key) - .pipe(take(1)); - } + const configRequests: Record> = {}; + for (const key of installableKeys) { + configRequests[key] = this.pluginManagementService + .getPluginConfigurationsByPluginDefinitionKey(key) + .pipe(take(1)); + } - forkJoin(configRequests) - .pipe(take(1)) - .subscribe(results => { - const configsByKey = new Map(Object.entries(results)); - this.buildMappingRows(uniqueConfigs, configsByKey, installedKeys); - }); + return forkJoin(configRequests).pipe( + take(1), + map(results => { + const configsByKey = new Map(Object.entries(results)); + return this.buildEmbeddedRows(uniqueConfigs, configsByKey, installedKeys); + }) + ); + }) + ); } - private buildMappingRows( + private buildEmbeddedRows( uniqueConfigs: PluginConfigurationPreview[], configsByKey: Map, installedKeys: Set - ): void { - this.clearPluginMappingForm(); - const rows: PluginMappingRow[] = uniqueConfigs.map(config => { + ): PluginMappingRow[] { + return uniqueConfigs.map(config => { const key = config.pluginDefinitionKey; const isInstalled = key ? installedKeys.has(key) : false; const available = configsByKey.get(key) || []; @@ -378,13 +435,6 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { selected: c.id === defaultSelectionId, })); - if (status === 'available') { - this.pluginMappingForm.addControl( - config.pluginConfigurationId, - this.fb.control(defaultSelectionId) - ); - } - return { pluginDefinitionKey: key, pluginDefinitionTitle: this.getPluginTitle(key), @@ -392,9 +442,101 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { existsInTargetEnvironment: config.existsInTargetEnvironment, listItems, status, + source: 'embedded' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: null, + mismatchedVersionsById: new Map(), }; }); - this.pluginMappingRows$.next(rows); + } + + private loadExternalMappingRows( + uniqueConfigs: PluginConfigurationPreview[] + ): Observable { + if (uniqueConfigs.length === 0) { + return of([]); + } + + return combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + take(1), + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const lang = this.translateService.currentLang; + + return uniqueConfigs.map(config => { + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === config.pluginDefinitionKey; + }); + + let status: PluginMappingStatus; + if (matchingConfigurations.length === 0) { + status = definitionById.size > 0 && + [...definitionById.values()].some(d => d.pluginId === config.pluginDefinitionKey) + ? 'no-configurations' + : 'not-installed'; + } else { + status = 'available'; + } + + const defaultSelectionId = config.existsInTargetEnvironment + ? config.pluginConfigurationId + : null; + + const mismatchedVersionsById = new Map(); + const listItems: ListItem[] = matchingConfigurations.map(configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== config.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + content: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + id: configuration.id, + selected: configuration.id === defaultSelectionId, + }; + }); + + return { + pluginDefinitionKey: config.pluginDefinitionKey, + pluginDefinitionTitle: this.getExternalPluginTitle(config, definitionById), + sourcePluginConfigurationId: config.pluginConfigurationId, + existsInTargetEnvironment: config.existsInTargetEnvironment, + listItems, + status, + source: 'external' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: config.pluginDefinitionVersion ?? null, + mismatchedVersionsById, + }; + }); + }) + ); + } + + private getExternalPluginTitle( + config: PluginConfigurationPreview, + definitionById: Map + ): string { + if (!config.pluginDefinitionKey) { + return this.translateService.instant('caseManagement.importDefinition.plugins.unknownPlugin'); + } + const lang = this.translateService.currentLang; + const matchingDefinition = [...definitionById.values()].find( + d => d.pluginId === config.pluginDefinitionKey && d.version === config.pluginDefinitionVersion + ); + if (matchingDefinition) { + return getExternalPluginDisplayName(matchingDefinition, lang); + } + return config.pluginDefinitionVersion + ? `${config.pluginDefinitionKey} (${config.pluginDefinitionVersion})` + : config.pluginDefinitionKey; } private clearPluginMappingForm(): void { @@ -489,11 +631,14 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private buildPluginConfigurationMappings(): Record { const mappings: Record = {}; for (const row of this.pluginMappingRows$.value) { + // Only rows the user could actually act on are sent. An explicit null tells the importer + // to clear the configuration id (a deliberate dangling import); omitting the key keeps the + // original id. Rows without a dropdown (plugin not installed / no configurations — possibly + // a transient lookup failure) must not silently clear ids: a cleared id disappears from the + // next export's import preview, making the mapping unrecoverable through the wizard. if (row.status === 'available') { const control = this.pluginMappingForm.get(row.sourcePluginConfigurationId); mappings[row.sourcePluginConfigurationId] = control?.value ?? null; - } else { - mappings[row.sourcePluginConfigurationId] = null; } } return mappings; diff --git a/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts b/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts index 3391ffda56..b45fe4cec8 100644 --- a/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts +++ b/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts @@ -66,6 +66,8 @@ export interface CaseDefinitionConfigurationIssue { resolvedAt: string | null; } +export type PluginConfigurationPreviewSource = 'embedded' | 'external'; + export interface PluginConfigurationPreview { pluginConfigurationId: string; pluginDefinitionKey: string | null; @@ -73,6 +75,8 @@ export interface PluginConfigurationPreview { processDefinitionKey: string; activityId: string; existsInTargetEnvironment: boolean; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; } export interface CaseDefinitionImportPreview { @@ -86,6 +90,8 @@ export interface CaseDefinitionImportPreview { export interface DanglingPluginConfiguration { pluginDefinitionKey: string | null; sourcePluginConfigurationIds: string[]; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; } export type PluginMappingStatus = 'available' | 'no-configurations' | 'not-installed'; @@ -96,6 +102,14 @@ export interface MappingRow { sourcePluginConfigurationIds: string[]; selectItems: SelectItem[]; status: PluginMappingStatus; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; + /** + * External configuration id -> actual definition version, populated only for options whose + * version differs from `pluginDefinitionVersion` (D3 non-blocking warning). Empty/undefined for + * embedded rows and exact-version-only sets. + */ + mismatchedVersionsById?: Map; } export interface ConfigurationIssueUpdatedSseEvent { diff --git a/frontend/projects/valtimo/case-management/src/lib/models/tab.model.ts b/frontend/projects/valtimo/case-management/src/lib/models/tab.model.ts index 847d6ae9bd..127c862b77 100644 --- a/frontend/projects/valtimo/case-management/src/lib/models/tab.model.ts +++ b/frontend/projects/valtimo/case-management/src/lib/models/tab.model.ts @@ -14,6 +14,19 @@ * limitations under the License. */ +/** A `case-tab` bundle exposed by an external plugin (the second dropdown in the tab editor). */ +export interface ExternalPluginTabBundleOption { + key: string | null; + title: string; +} + +/** An activated external-plugin configuration that exposes ≥1 `case-tab` bundle (the first dropdown). */ +export interface ExternalPluginTabConfigOption { + configId: string; + label: string; + bundles: ExternalPluginTabBundleOption[]; +} + export enum TabEnum { GENERAL = 'general', DOCUMENT = 'document', diff --git a/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts b/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts index 9a172fbf2a..260d1019b1 100644 --- a/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts +++ b/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts @@ -24,9 +24,11 @@ import { getCaseManagementRouteParams, } from '@valtimo/shared'; import {FormDefinitionOption, FormService} from '@valtimo/form'; +import {ExternalPluginWidgetConfigOption} from '@valtimo/layout'; +import {ExternalPluginService, getExternalPluginDisplayName} from '@valtimo/plugin'; import {ListItem} from 'carbon-components-angular'; -import {BehaviorSubject, combineLatest, map, Observable, of, switchMap} from 'rxjs'; -import {TabEnum} from '../models'; +import {BehaviorSubject, catchError, combineLatest, map, Observable, of, switchMap} from 'rxjs'; +import {ExternalPluginTabConfigOption, TabEnum} from '../models'; @Injectable({ providedIn: 'root', @@ -82,7 +84,8 @@ export class TabService { @Inject(CASE_MANAGEMENT_TAB_TOKEN) private readonly caseManagementTabConfig: CaseManagementTabConfig[], private readonly formService: FormService, - private readonly translateService: TranslateService + private readonly translateService: TranslateService, + private readonly externalPluginService: ExternalPluginService ) { this.setInjectedCaseManagementTabs(this.caseManagementTabConfig); } @@ -92,14 +95,16 @@ export class TabService { custom: boolean; formIO: boolean; widgets: boolean; + externalPlugin: boolean; }> { return combineLatest([ this.configuredContentKeys$, this.getFormDefinitions(route), this.defaultTabs$, this.customComponentKeys$, + this.getExternalPluginTabItems(), ]).pipe( - map(([tabKeys, formDefinitions, defaultTabs, customComponentKeys]) => ({ + map(([tabKeys, formDefinitions, defaultTabs, customComponentKeys, externalPluginItems]) => ({ standard: defaultTabs.every((tabItem: ListItem) => tabKeys.includes(tabItem.contentKey)), custom: !customComponentKeys.length || @@ -108,10 +113,139 @@ export class TabService { !formDefinitions.length || formDefinitions.every((tabItem: ListItem) => tabKeys.includes(tabItem.contentKey)), widgets: false, + externalPlugin: + !externalPluginItems.length || + externalPluginItems.every((tabItem: ListItem) => tabKeys.includes(tabItem.contentKey)), })) ); } + /** + * Lists the selectable content keys for `EXTERNAL_PLUGIN` tabs: one entry per `case-tab` bundle of + * each activated (`AVAILABLE`) external-plugin configuration. The `contentKey` encodes + * `"[:]"` — consumed server-side (Phase 2.7) to create the side row. + * + * Degrades to an empty list when the external-plugin endpoints are unavailable (module absent). + */ + public getExternalPluginTabItems(): Observable { + return combineLatest([ + this.externalPluginService.getDefinitions(), + this.externalPluginService.getConfigurations(), + ]).pipe( + map(([definitions, configurations]) => { + const lang = this.translateService.currentLang; + const definitionById = new Map(definitions.map(definition => [definition.id, definition])); + const items: ListItem[] = []; + + configurations.forEach(configuration => { + const definition = definitionById.get(configuration.definitionId); + if (!definition || definition.status !== 'AVAILABLE') return; + + const caseTabBundles = (definition.manifest?.frontendBundles ?? []).filter( + bundle => bundle.type === 'case-tab' + ); + if (!caseTabBundles.length) return; + + const pluginName = getExternalPluginDisplayName(definition, lang); + caseTabBundles.forEach(bundle => { + const contentKey = bundle.key ? `${configuration.id}:${bundle.key}` : configuration.id; + const bundleSuffix = + caseTabBundles.length > 1 ? ` — ${bundle.title ?? bundle.key}` : ''; + items.push({ + contentKey, + content: `${configuration.title} (${pluginName})${bundleSuffix}`, + selected: false, + }); + }); + }); + + return items; + }), + catchError(() => of([] as ListItem[])) + ); + } + + /** + * Activated external-plugin configurations that expose ≥1 `case-tab` bundle, grouped so the tab + * editor can offer two dropdowns: pick the configuration, then the tab (bundle). The `contentKey` + * the editor writes is `"[:]"` (Phase 2.7). Degrades to an empty list when the + * external-plugin endpoints are unavailable. + */ + public getExternalPluginConfigs(): Observable { + return combineLatest([ + this.externalPluginService.getDefinitions(), + this.externalPluginService.getConfigurations(), + ]).pipe( + map(([definitions, configurations]) => { + const lang = this.translateService.currentLang; + const definitionById = new Map(definitions.map(definition => [definition.id, definition])); + + return configurations.reduce((options, configuration) => { + const definition = definitionById.get(configuration.definitionId); + if (!definition || definition.status !== 'AVAILABLE') return options; + + const caseTabBundles = (definition.manifest?.frontendBundles ?? []).filter( + bundle => bundle.type === 'case-tab' + ); + if (!caseTabBundles.length) return options; + + options.push({ + configId: configuration.id, + label: `${configuration.title} (${getExternalPluginDisplayName(definition, lang)})`, + bundles: caseTabBundles.map(bundle => ({ + key: bundle.key ?? null, + title: bundle.title ?? bundle.key ?? 'case-tab', + })), + }); + return options; + }, []); + }), + catchError(() => of([] as ExternalPluginTabConfigOption[])) + ); + } + + /** + * Activated external-plugin configurations that expose ≥1 `case-widget` bundle, grouped so the + * widget config editor can offer two combo boxes (configuration, then widget bundle). The widget + * `case-widget` counterpart of {@link getExternalPluginConfigs}. Degrades to an empty list when the + * external-plugin endpoints are unavailable (module absent). + */ + public getExternalPluginWidgetConfigs(): Observable { + return combineLatest([ + this.externalPluginService.getDefinitions(), + this.externalPluginService.getConfigurations(), + ]).pipe( + map(([definitions, configurations]) => { + const lang = this.translateService.currentLang; + const definitionById = new Map(definitions.map(definition => [definition.id, definition])); + + return configurations.reduce( + (options, configuration) => { + const definition = definitionById.get(configuration.definitionId); + if (!definition || definition.status !== 'AVAILABLE') return options; + + const caseWidgetBundles = (definition.manifest?.frontendBundles ?? []).filter( + bundle => bundle.type === 'case-widget' + ); + if (!caseWidgetBundles.length) return options; + + options.push({ + configId: configuration.id, + label: `${configuration.title} (${getExternalPluginDisplayName(definition, lang)})`, + bundles: caseWidgetBundles.map(bundle => ({ + key: bundle.key ?? null, + title: bundle.title ?? bundle.key ?? 'case-widget', + })), + }); + return options; + }, + [] + ); + }), + catchError(() => of([] as ExternalPluginWidgetConfigOption[])) + ); + } + public getFormDefinitions(route: ActivatedRoute): Observable { return getCaseManagementRouteParams(route).pipe( switchMap((params: CaseManagementParams) => { diff --git a/frontend/projects/valtimo/case/src/lib/case.module.ts b/frontend/projects/valtimo/case/src/lib/case.module.ts index 579a02ae14..75156ca6e8 100644 --- a/frontend/projects/valtimo/case/src/lib/case.module.ts +++ b/frontend/projects/valtimo/case/src/lib/case.module.ts @@ -43,6 +43,9 @@ import { MenuService, ModalModule, ObserveSizeDirective, + OverflowMenuComponent, + OverflowMenuOptionComponent, + OverflowMenuTriggerComponent, ParagraphModule, QuickSearchComponent, RemoveClassnamesDirective, @@ -55,9 +58,6 @@ import { TimelineModule, UploaderModule, ValtimoCdsModalDirective, - OverflowMenuComponent, - OverflowMenuOptionComponent, - OverflowMenuTriggerComponent, VModalModule, WidgetModule, } from '@valtimo/components'; @@ -106,6 +106,7 @@ import {CaseDetailTabFormioComponent} from './components/case-detail/tab/formio/ import {TabTranslatePipeModule} from './pipes'; import {CaseDetailTabNotFoundComponent} from './components/case-detail/tab/not-found/not-found.component'; import {CaseDetailWidgetsComponent} from './components/case-detail/tab/widgets/widgets.component'; +import {CaseDetailExternalPluginTabComponent} from './components/case-detail/tab/external-plugin/external-plugin.component'; import {CaseDetailTaskListComponent} from './components/case-detail-task-list/case-detail-task-list.component'; import {CaseDetailsTaskDetailComponent} from './components/case-detail-task-detail/case-detail-task-detail.component'; import {AngularSplitModule} from 'angular-split'; @@ -195,6 +196,7 @@ export type TabsFactory = () => Map; OverflowMenuOptionComponent, OverflowMenuTriggerComponent, CaseDetailWidgetsComponent, + CaseDetailExternalPluginTabComponent, CaseDetailTaskListComponent, CaseDetailsTaskDetailComponent, AngularSplitModule, diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.scss b/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.scss index a55c2963a1..7419c101fc 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.scss +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.scss @@ -37,6 +37,14 @@ min-height: unset; } + // Height/overflow clamping only applies to the external-plugin tab (which sizes its own iframe + // via fitPage). Other tabs sharing .tab--no-min-height (widgets, documents) keep the default + // scroll behaviour and must not be clipped. + &:has(> .tab--external-plugin) { + height: auto; + overflow: hidden; + } + &:has(> .tab--no-background) { background: transparent !important; } @@ -47,6 +55,12 @@ gap: 32px; overflow: auto; margin-bottom: 16px; + + // Scoped to the external-plugin tab only — see the note on .tab--external-plugin above. + &:has(.tab--external-plugin) { + margin-bottom: 0; + max-height: none !important; + } } .loading-container { diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.ts index 7b77cdfc75..f7152e3255 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.ts +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/case-detail.component.ts @@ -786,7 +786,7 @@ export class CaseDetailComponent implements AfterViewInit, OnDestroy { ...(isAdmin && { actions: [ { - text: this.translateService.instant('dossier.configure'), + text: this.translateService.instant('case.configure'), click: () => this.router.navigate(['/process-links']), }, ], diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.html b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.html new file mode 100644 index 0000000000..ddd391fd96 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.html @@ -0,0 +1,47 @@ + + + +
+ +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.loadError' | translate }} +
+ + +
+ +
+ + +
+
diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.scss b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.scss new file mode 100644 index 0000000000..15f8d7c2d6 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.scss @@ -0,0 +1,37 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: block; +} + +:host ::ng-deep .external-plugin-iframe { + width: 100%; + height: 100%; + border: none; +} + +.external-plugin-tab__status { + display: flex; + justify-content: center; + align-items: center; + padding: 32px 0; + + &--error { + color: var(--cds-support-error); + font-size: 14px; + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.ts new file mode 100644 index 0000000000..e65f3b7ed7 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/external-plugin/external-plugin.component.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + HostBinding, + OnDestroy, + OnInit, + signal, +} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {TranslateModule} from '@ngx-translate/core'; +import { + derivePluginDataUrl, + ExternalPluginIframeComponent, + ExternalPluginSessionService, +} from '@valtimo/plugin'; +import {FitPageDirective} from '@valtimo/components'; +import {LoadingModule} from 'carbon-components-angular'; +import {combineLatest, filter, map, Observable, Subscription, switchMap, throwError} from 'rxjs'; +import {CaseExternalPluginTabApiService, CaseTabService} from '../../../../services'; +import {ExternalPluginTabContent, ExternalPluginTabState} from '../../../../models'; + +@Component({ + templateUrl: './external-plugin.component.html', + styleUrls: ['./external-plugin.component.scss'], + standalone: true, + providers: [ExternalPluginSessionService], + imports: [ + CommonModule, + LoadingModule, + TranslateModule, + ExternalPluginIframeComponent, + FitPageDirective, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CaseDetailExternalPluginTabComponent implements OnInit, OnDestroy { + @HostBinding('class.tab--no-margin') private readonly _noMargin = true; + @HostBinding('class.tab--no-background') private readonly _noBackground = true; + @HostBinding('class.tab--no-min-height') private readonly _noMinHeight = true; + // Carries the external-plugin-specific height/overflow overrides in case-detail.component.scss, + // so they no longer leak onto other tabs that use .tab--no-min-height (widgets, documents). + @HostBinding('class.tab--external-plugin') private readonly _externalPlugin = true; + + public readonly $state = signal('loading'); + public readonly $content = signal(null); + public readonly $pluginDataUrl = signal(null); + public readonly $iframeReady = signal(false); + + private readonly _documentId$: Observable = this.route.params.pipe( + map(params => params?.documentId), + filter(documentId => !!documentId) + ); + private readonly _tabKey$: Observable = this.caseTabService.activeTabKey$; + + private readonly _subscriptions = new Subscription(); + + constructor( + private readonly route: ActivatedRoute, + private readonly caseTabService: CaseTabService, + private readonly apiService: CaseExternalPluginTabApiService, + protected readonly sessionService: ExternalPluginSessionService + ) {} + + public ngOnInit(): void { + this._subscriptions.add( + combineLatest([this._documentId$, this._tabKey$]) + .pipe( + switchMap(([documentId, tabKey]) => + this.apiService + .getExternalPluginTab(documentId, tabKey) + .pipe( + switchMap(content => + content?.bundleUrl + ? this.sessionService + .startSession(content.configurationId) + .pipe(map(() => content)) + : throwError(() => new Error('bundle-unavailable')) + ) + ) + ) + ) + .subscribe({ + next: content => this.onLoaded(content), + error: () => this.$state.set('error'), + }) + ); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onIframeReady(): void { + this.$iframeReady.set(true); + // Force fitPage recalculation after CSS :has() selectors have settled + setTimeout(() => window.dispatchEvent(new Event('resize')), 50); + } + + private onLoaded(content: ExternalPluginTabContent): void { + this.$content.set(content); + this.$pluginDataUrl.set(derivePluginDataUrl(content.bundleUrl)); + this.$state.set('ready'); + // Trigger fitPage recalculation after the iframe element is rendered + setTimeout(() => window.dispatchEvent(new Event('resize')), 100); + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html new file mode 100644 index 0000000000..dea17a345f --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html @@ -0,0 +1,83 @@ + + +
+ @if ($widget(); as widget) { +
+
+ + + + {{ widget.title }} + +
+ + @if ( + widget.actions?.length === 1 && + widget.actions[0]?.processDefinitionKey && + (canCreateCamundaExecution$ | async) + ) { + + } + + +
+ } + +
+
+ +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.widgetLoadError' | translate }} +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.unavailable' | translate }} +
+ + +
+ +
+ + +
+
+
diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss new file mode 100644 index 0000000000..15e1ac0f27 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss @@ -0,0 +1,100 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.case-widget-external-plugin { + display: flex; + flex-direction: column; + width: 100%; + padding: 24px; + box-sizing: border-box; + color: var(--widget-text-color, var(--cds-text-primary)); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; + } + + &__title-container { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + + valtimo-mdi-icon-viewer .mdi-icon-preview { + color: var(--widget-text-color, var(--cds-text-primary)); + } + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--widget-text-color, var(--cds-text-primary)); + font-weight: 600; + font-size: 16px; + line-height: 24px; + } + + &__body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + margin-top: 16px; + // The iframe has no intrinsic height (no host-side resize handling), so the plugin canvas gets a + // sensible default. A one-off fixed box — no Carbon spacing token maps to a widget content + // height. The card itself is sized from measured content, so the header adds on top of this. + height: 320px; + } + + &__status { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding: var(--cds-spacing-05); + color: var(--cds-text-secondary); + } + + &__status--error { + color: var(--cds-text-error); + } + + valtimo-external-plugin-iframe { + display: block; + flex: 1 1 auto; + width: 100%; + height: 100%; + } + + &--compact { + padding: 12px; + + .case-widget-external-plugin__title { + font-size: 14px; + line-height: 20px; + } + + .case-widget-external-plugin__body { + margin-top: 8px; + } + + .case-widget-external-plugin__status { + padding: var(--cds-spacing-03); + } + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts new file mode 100644 index 0000000000..67fccb5e42 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts @@ -0,0 +1,172 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal} from '@angular/core'; +import {TranslateModule} from '@ngx-translate/core'; +import {PermissionService} from '@valtimo/access-control'; +import {MdiIconViewerComponent} from '@valtimo/components'; +import {DocumentService} from '@valtimo/document'; +import { + ExternalPluginWidget, + WidgetAction, + WidgetActionButtonComponent, + WidgetLayoutService, +} from '@valtimo/layout'; +import { + derivePluginDataUrl, + ExternalPluginIframeComponent, + ExternalPluginSessionService, +} from '@valtimo/plugin'; +import {ButtonModule, LoadingModule} from 'carbon-components-angular'; +import { + BehaviorSubject, + combineLatest, + filter, + map, + Observable, + Subscription, + switchMap, + throwError, +} from 'rxjs'; +import {CaseTabService, CaseWidgetsApiService} from '../../../../../../services'; +import {ExternalPluginWidgetContent, ExternalPluginWidgetState} from '../../../../../../models'; +import {WidgetsService} from '../../widgets.service'; +import {WidgetProcess} from '../widget-process/widget-process'; + +@Component({ + selector: 'valtimo-case-widget-external-plugin', + templateUrl: './case-widget-external-plugin.component.html', + styleUrls: ['./case-widget-external-plugin.component.scss'], + standalone: true, + providers: [ExternalPluginSessionService], + imports: [ + ButtonModule, + CommonModule, + LoadingModule, + TranslateModule, + ExternalPluginIframeComponent, + MdiIconViewerComponent, + WidgetActionButtonComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CaseWidgetExternalPluginComponent extends WidgetProcess implements OnInit, OnDestroy { + @Input({required: true}) public set documentId(value: string) { + this.baseDocumentId = value; + this._documentId$.next(value); + } + + @Input() public set widgetConfiguration(value: ExternalPluginWidget) { + if (!value) return; + this.$widget.set(value); + this.baseWidgetConfiguration = value; + this._widgetConfiguration$.next(value); + } + + @Input() public readonly widgetUuid: string; + + public readonly $widget = signal(null); + public readonly $state = signal('loading'); + public readonly $content = signal(null); + public readonly $pluginDataUrl = signal(null); + public readonly $iframeReady = signal(false); + + private readonly _documentId$ = new BehaviorSubject(null); + private readonly _widgetConfiguration$ = new BehaviorSubject(null); + private readonly _tabKey$: Observable = this.caseTabService.activeTabKey$; + + private readonly _subscriptions = new Subscription(); + + constructor( + protected readonly documentService: DocumentService, + protected readonly permissionService: PermissionService, + private readonly caseTabService: CaseTabService, + private readonly caseWidgetsApiService: CaseWidgetsApiService, + private readonly widgetLayoutService: WidgetLayoutService, + private readonly widgetsService: WidgetsService, + protected readonly sessionService: ExternalPluginSessionService + ) { + super(documentService, permissionService); + } + + public ngOnInit(): void { + this._subscriptions.add( + combineLatest([ + this._documentId$.pipe(filter((documentId): documentId is string => !!documentId)), + this._widgetConfiguration$.pipe( + filter((widget): widget is ExternalPluginWidget => !!widget) + ), + this._tabKey$, + ]) + .pipe( + switchMap(([documentId, widget, tabKey]) => + ( + this.caseWidgetsApiService.getWidgetData( + documentId, + tabKey, + widget.key + ) as unknown as Observable + ).pipe( + switchMap(content => + content?.bundleUrl && content.configurationId + ? this.sessionService + .startSession(content.configurationId) + .pipe(map(() => content)) + : throwError(() => new Error('bundle-unavailable')) + ) + ) + ) + ) + .subscribe({ + next: content => this.onLoaded(content), + error: error => this.onFailed(error), + }) + ); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onIframeReady(): void { + this.$iframeReady.set(true); + this.markDataLoaded(); + } + + public onProcessStartClick(process: WidgetAction): void { + if (!process.processDefinitionKey) return; + this.widgetsService.startProcess(process.processDefinitionKey); + } + + private onLoaded(content: ExternalPluginWidgetContent): void { + this.$content.set(content); + this.$pluginDataUrl.set(derivePluginDataUrl(content.bundleUrl)); + this.$state.set('ready'); + } + + private onFailed(error: Error | null): void { + this.$state.set(error?.message === 'bundle-unavailable' ? 'unavailable' : 'error'); + // The widget will never reach iframe-ready, so release the container's loading state here — + // otherwise a tab (or divider group) holding only failed external-plugin widgets spins forever + // and keeps the error/unavailable message hidden. Mirrors what first-party widgets do on a 404. + this.markDataLoaded(); + } + + private markDataLoaded(): void { + if (this.widgetUuid) this.widgetLayoutService.setWidgetDataLoaded(this.widgetUuid); + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts index 7cc047c522..3e9137b1ee 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts @@ -21,16 +21,8 @@ import {CarbonListModule, WidgetLayout} from '@valtimo/components'; import {LoadingModule} from 'carbon-components-angular'; import {combineLatest, filter, map, Observable, shareReplay, startWith, switchMap} from 'rxjs'; import {CaseTabService, CaseWidgetsApiService} from '../../../../services'; -import {CaseWidgetsRes} from '../../../../models'; -import { - BasicWidget, - WidgetComponentMap, - WidgetContainerComponent, - WidgetType, - DividerWidget, - Widget, - WidgetGroup, -} from '@valtimo/layout'; +import {CaseWidgetsRes, DocumentUpdatedSseEvent} from '../../../../models'; +import {BasicWidget, DividerWidget, Widget, WidgetComponentMap, WidgetContainerComponent, WidgetGroup, WidgetType,} from '@valtimo/layout'; import {CaseWidgetFieldComponent} from './components/field/case-widget-field.component'; import {CaseWidgetCustomComponent} from './components/custom/case-widget-custom.component'; import {CaseWidgetFormioComponent} from './components/formio/case-widget-formio.component'; @@ -41,8 +33,8 @@ import {CaseWidgetPersonCardComponent} from './components/person-card/case-widge import {CaseWidgetMetrolineComponent} from './components/metroline/case-widget-metroline.component'; import {CaseWidgetHighlightComponent} from './components/highlight/case-widget-highlight.component'; import {CaseWidgetImageComponent} from './components/image/case-widget-image.component'; +import {CaseWidgetExternalPluginComponent} from './components/external-plugin/case-widget-external-plugin.component'; import {CaseWidgetTextComponent} from './components/text/case-widget-text.component'; -import {DocumentUpdatedSseEvent} from '../../../../models'; import {SseService} from '@valtimo/sse'; import {WidgetsService} from './widgets.service'; import {isEqual} from 'lodash-es'; @@ -116,6 +108,7 @@ export class CaseDetailWidgetsComponent implements OnInit, OnDestroy { [WidgetType.METROLINE]: CaseWidgetMetrolineComponent, [WidgetType.HIGHLIGHT]: CaseWidgetHighlightComponent, [WidgetType.IMAGE]: CaseWidgetImageComponent, + [WidgetType.EXTERNAL_PLUGIN]: CaseWidgetExternalPluginComponent, [WidgetType.TEXT]: CaseWidgetTextComponent, }; diff --git a/frontend/projects/valtimo/case/src/lib/models/external-plugin-tab.model.ts b/frontend/projects/valtimo/case/src/lib/models/external-plugin-tab.model.ts new file mode 100644 index 0000000000..ee7ff986f5 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/models/external-plugin-tab.model.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface ExternalPluginTabContext { + documentId: string; + caseDefinitionKey: string; + caseDefinitionVersionTag: string; + pluginConfigurationId: string; +} + +interface ExternalPluginTabContent { + bundleUrl: string | null; + configurationId: string; + bundleKey: string | null; + context: ExternalPluginTabContext; +} + +type ExternalPluginTabState = 'loading' | 'ready' | 'error'; + +export {ExternalPluginTabContext, ExternalPluginTabContent, ExternalPluginTabState}; diff --git a/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts b/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts new file mode 100644 index 0000000000..20788ac668 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface ExternalPluginWidgetContext { + documentId: string; + caseDefinitionKey: string; + caseDefinitionVersionTag: string; + pluginConfigurationId: string | null; +} + +/** + * Descriptor returned by the widget-data endpoint for an `external-plugin` widget. Mirrors the + * case-tab content but the configuration id may be `null` (a widget that imported dangling), in + * which case `bundleUrl` is `null` and the widget renders an unavailable state. + */ +interface ExternalPluginWidgetContent { + bundleUrl: string | null; + configurationId: string | null; + bundleKey: string | null; + context: ExternalPluginWidgetContext; +} + +type ExternalPluginWidgetState = 'loading' | 'ready' | 'error' | 'unavailable'; + +export {ExternalPluginWidgetContext, ExternalPluginWidgetContent, ExternalPluginWidgetState}; diff --git a/frontend/projects/valtimo/case/src/lib/models/index.ts b/frontend/projects/valtimo/case/src/lib/models/index.ts index 22a53ff5c4..3ac711f26d 100644 --- a/frontend/projects/valtimo/case/src/lib/models/index.ts +++ b/frontend/projects/valtimo/case/src/lib/models/index.ts @@ -25,3 +25,5 @@ export * from './case-sse-event.model'; export * from './case-list-quick-search.model'; export * from './case-widget.model'; export * from './case-inspection.models'; +export * from './external-plugin-tab.model'; +export * from './external-plugin-widget.model'; diff --git a/frontend/projects/valtimo/case/src/lib/models/tab-api.model.ts b/frontend/projects/valtimo/case/src/lib/models/tab-api.model.ts index 98e4d83ce4..964a24a193 100644 --- a/frontend/projects/valtimo/case/src/lib/models/tab-api.model.ts +++ b/frontend/projects/valtimo/case/src/lib/models/tab-api.model.ts @@ -19,6 +19,7 @@ enum ApiTabType { FORMIO = 'formio', CUSTOM = 'custom', WIDGETS = 'widgets', + EXTERNAL_PLUGIN = 'external_plugin', MAP = 'map', } diff --git a/frontend/projects/valtimo/case/src/lib/services/case-external-plugin-tab-api.service.ts b/frontend/projects/valtimo/case/src/lib/services/case-external-plugin-tab-api.service.ts new file mode 100644 index 0000000000..c79de7d23b --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/services/case-external-plugin-tab-api.service.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {BaseApiService, ConfigService} from '@valtimo/shared'; +import {Observable} from 'rxjs'; +import {ExternalPluginTabContent} from '../models'; + +@Injectable({ + providedIn: 'root', +}) +export class CaseExternalPluginTabApiService extends BaseApiService { + constructor( + protected readonly httpClient: HttpClient, + protected readonly configService: ConfigService + ) { + super(httpClient, configService); + } + + public getExternalPluginTab( + documentId: string, + tabKey: string + ): Observable { + return this.httpClient.get( + this.getApiUrl(`v1/document/${documentId}/external-plugin-tab/${tabKey}`) + ); + } +} diff --git a/frontend/projects/valtimo/case/src/lib/services/case-tab.service.ts b/frontend/projects/valtimo/case/src/lib/services/case-tab.service.ts index 0916a50a84..c26dd56440 100644 --- a/frontend/projects/valtimo/case/src/lib/services/case-tab.service.ts +++ b/frontend/projects/valtimo/case/src/lib/services/case-tab.service.ts @@ -39,6 +39,7 @@ import { import {CaseDetailTabFormioComponent} from '../components/case-detail/tab/formio/formio.component'; import {CaseDetailTabNotFoundComponent} from '../components/case-detail/tab/not-found/not-found.component'; import {CaseDetailWidgetsComponent} from '../components/case-detail/tab/widgets/widgets.component'; +import {CaseDetailExternalPluginTabComponent} from '../components/case-detail/tab/external-plugin/external-plugin.component'; @Injectable() export class CaseTabService implements OnDestroy { @@ -144,14 +145,17 @@ export class CaseTabService implements OnDestroy { private openCaseDefinitionKeySubscription(): void { this._subscriptions.add( - combineLatest([this._caseDefinitionKey$, this._documentId$, this._tabManagementEnabled$]) - .subscribe(([caseDefinitionKey, documentId, tabManagementEnabled]) => { - if (tabManagementEnabled) { - this.setApiTabs(caseDefinitionKey, documentId); - } else { - this.setEnvironmentTabs(caseDefinitionKey); - } - }) + combineLatest([ + this._caseDefinitionKey$, + this._documentId$, + this._tabManagementEnabled$, + ]).subscribe(([caseDefinitionKey, documentId, tabManagementEnabled]) => { + if (tabManagementEnabled) { + this.setApiTabs(caseDefinitionKey, documentId); + } else { + this.setEnvironmentTabs(caseDefinitionKey); + } + }) ); } @@ -223,6 +227,15 @@ export class CaseTabService implements OnDestroy { tab.name ?? '', tab.showTasks ); + case ApiTabType.EXTERNAL_PLUGIN: + return new TabImpl( + tab.key, + index, + CaseDetailExternalPluginTabComponent, + tab.contentKey, + tab.name ?? '', + tab.showTasks + ); default: return null; } diff --git a/frontend/projects/valtimo/case/src/lib/services/index.ts b/frontend/projects/valtimo/case/src/lib/services/index.ts index cd5632c178..ccddc1aa4b 100644 --- a/frontend/projects/valtimo/case/src/lib/services/index.ts +++ b/frontend/projects/valtimo/case/src/lib/services/index.ts @@ -36,4 +36,5 @@ export * from './case-list-hidden-columns.service'; export * from './case-list-quick-search.service'; export * from './case-list-orchestration.service'; export * from './case-inspection.service'; +export * from './case-external-plugin-tab-api.service'; export * from './case-process-timer.service'; diff --git a/frontend/projects/valtimo/components/src/lib/components/carbon-list/carbon-list.component.html b/frontend/projects/valtimo/components/src/lib/components/carbon-list/carbon-list.component.html index 7bc23a8447..e04fc6f3c6 100644 --- a/frontend/projects/valtimo/components/src/lib/components/carbon-list/carbon-list.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/carbon-list/carbon-list.component.html @@ -130,6 +130,7 @@ [portalToBody]="true" (openChange)="handleActionOpenChange(data.item, $event)" placement="bottom-end" + [portalToBody]="true" (click)="$event.stopPropagation()" > diff --git a/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.html b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.html new file mode 100644 index 0000000000..79535a947a --- /dev/null +++ b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.html @@ -0,0 +1,96 @@ + + +
+ @for (item of items; track trackByIndex($index)) { +
+
+ + +
+ @if (showHandle) { + @if (wholeRowDraggable) { + + } @else if (!dragDisabled(item, $index)) { + + } + } + +
+ +
+
+ + @if (expansionTemplate) { +
+ +
+ } +
+ } + + @if (!items?.length && emptyTemplate) { + + } +
diff --git a/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.scss b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.scss new file mode 100644 index 0000000000..3b00e0e2e2 --- /dev/null +++ b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.scss @@ -0,0 +1,143 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.valtimo-drag-drop-list { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-03); + min-height: var(--cds-spacing-09); + + // Empty list: a dashed drop zone so the empty area is still an obvious target. + &--empty { + border: 1px dashed var(--cds-border-subtle); + border-radius: var(--cds-spacing-02); + } + + // Drop feedback (the host adds a "--dragging" desaturation for the inverse). A list that can accept + // the dragged item gets a subtle dashed hint; the list the pointer is actually over gets a thin + // interactive outline. The exact drop position is shown by the placeholder, not a full-list fill — + // so it stays clear where the item will land even for nested lists. + &.cdk-drop-list-receiving { + outline: 1px dashed var(--cds-border-subtle); + outline-offset: calc(-1 * var(--cds-spacing-01)); + border-radius: var(--cds-spacing-02); + } + + &.cdk-drop-list-dragging { + outline: 1px solid var(--cds-border-interactive); + outline-offset: calc(-1 * var(--cds-spacing-01)); + border-radius: var(--cds-spacing-02); + } + + // The draggable unit: a transparent column holding the styled header row and (optionally) an + // expansion below it. Only the header row carries the surface styling, so a container's background, + // hover and handle never span its sub-list. + &__item { + display: flex; + flex-direction: column; + + &.cdk-drag-disabled .valtimo-drag-drop-list__row { + color: var(--cds-text-disabled); + background-color: var(--cds-layer); + cursor: default; + } + } + + // The header row: a filled surface one Carbon layer above its container (layer-02), so it stands out + // whatever the container layer is — layer-01 equals the panel surface in some themes, layer-02 does + // not — separated from siblings by space. + &__row { + display: flex; + align-items: center; + gap: var(--cds-spacing-03); + padding: var(--cds-spacing-03) var(--cds-spacing-04); + background-color: var(--cds-layer-02); + color: var(--cds-text-primary); + + &:hover { + background-color: var(--cds-layer-hover-02); + } + + // Whole-row drag: the entire row is the drag surface (interactive controls are excluded in TS). + &--grabbable { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + } + + &__handle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--cds-icon-secondary); + cursor: move; + + &:hover { + color: var(--cds-icon-primary); + } + + &:focus-visible { + outline: 2px solid var(--cds-focus); + outline-offset: 1px; + } + + // Decorative grab cue in whole-row mode — the row, not this icon, is the drag surface. + &--static { + cursor: inherit; + pointer-events: none; + } + } + + &__content { + flex: 1 1 auto; + min-width: 0; + } + + // The drop anchor: the exact slot the item will land in. Uses the same interactive accent as the + // active drop list so the "where it lands" language is consistent. + &__placeholder { + box-sizing: border-box; + min-height: var(--cds-spacing-07); + border: 1px dashed var(--cds-border-interactive); + background-color: var(--cds-layer-accent-01); + border-radius: var(--cds-spacing-02); + } +} + +// CDK drag-drop motion states. The preview clones the whole item; show only its header row as a +// lifted chip (the styled row provides the surface), never the expanded sub-list. +.cdk-drag-preview { + box-sizing: border-box; + box-shadow: var(--cds-spacing-01) var(--cds-spacing-02) var(--cds-spacing-03) rgba(0, 0, 0, 0.2); + + .valtimo-drag-drop-list__expansion { + display: none; + } +} + +.cdk-drag-animating { + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.cdk-drop-list-dragging .cdk-drag { + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.ts b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.ts new file mode 100644 index 0000000000..7779b59a1f --- /dev/null +++ b/frontend/projects/valtimo/components/src/lib/components/drag-drop-list/drag-drop-list.component.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import {CdkDrag, CdkDragDrop, CdkDropList, DragDropModule} from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + TemplateRef, +} from '@angular/core'; +import {Draggable16} from '@carbon/icons'; +import {TranslateModule} from '@ngx-translate/core'; +import {IconModule, IconService} from 'carbon-components-angular'; + +/** + * Reusable, presentational connected sortable list built on the (well-tested) Angular CDK + * `@angular/cdk/drag-drop`. It owns only the drag mechanics, Carbon-styled handle and + * placeholder/preview; the consumer renders each row via [itemTemplate] and mutates its own data in + * response to [droppedEvent] (re-emitted verbatim from CDK). Compose several instances — connected + * by [connectedTo] or a `cdkDropListGroup` wrapper — for nested or cross-list drag-and-drop. + * + * Set [sortingDisabled] on a "palette" list whose items should be copied (not moved) into another + * list: leave its own array untouched in the [droppedEvent] handler and clone the dragged item + * instead. + */ +@Component({ + standalone: true, + selector: 'valtimo-drag-drop-list', + templateUrl: './drag-drop-list.component.html', + styleUrls: ['./drag-drop-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, DragDropModule, IconModule, TranslateModule], +}) +export class DragDropListComponent { + @Input() public items: T[] = []; + @Input() public listId!: string; + @Input() public connectedTo: string[] = []; + @Input() public disabled = false; + @Input() public sortingDisabled = false; + @Input() public showHandle = true; + /** + * When true the entire row is a drag surface (not just the handle), while interactive controls + * inside the row — `button`, `a`, `input`, `select`, `textarea`, `[role="button"]` or anything + * marked `[data-no-drag]` — never initiate a drag, so they stay clickable. The handle icon is then + * shown only as a visual grab cue. + */ + @Input() public wholeRowDraggable = false; + @Input() public orientation: 'vertical' | 'horizontal' = 'vertical'; + /** + * Aria label for the drag handle. Accepts either an already-translated string or a translation + * key — the template runs the value through the `translate` pipe, which passes unknown keys + * (i.e. already-translated strings) through unchanged. + */ + @Input() public handleAriaLabel = 'interface.dragToReorder'; + @Input() public itemTemplate!: TemplateRef<{$implicit: T; index: number}>; + /** + * Optional content rendered *below* the styled row (e.g. a nested sub-list). It sits inside the + * draggable element so it moves with the item, but outside the row surface — so the row's + * background, hover and drag handle cover only the header, not the whole subtree. + */ + @Input() public expansionTemplate: TemplateRef<{$implicit: T; index: number}> | null = null; + @Input() public emptyTemplate: TemplateRef | null = null; + @Input() public enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean = () => true; + @Input() public dragDisabled: (item: T, index: number) => boolean = () => false; + + @Output() public droppedEvent = new EventEmitter>(); + /** Emitted when a drag begins / ends anywhere in this list, so a host can reflect a global "dragging" state. */ + @Output() public dragStartedEvent = new EventEmitter(); + @Output() public dragEndedEvent = new EventEmitter(); + + constructor(private readonly iconService: IconService) { + this.iconService.registerAll([Draggable16]); + } + + public trackByIndex(index: number): number { + return index; + } + + /** + * In whole-row mode the row element is the CDK drag surface. CDK listens for `mousedown`/ + * `touchstart` on that element in the bubble phase, so stopping propagation here — only when the + * pointer went down on an interactive control — prevents a drag from starting on that control + * while leaving its click intact. A pointer-down anywhere else bubbles through and drags the row. + */ + public onContentPointerDown(event: Event): void { + if (!this.wholeRowDraggable) return; + const target = event.target as Element | null; + if (target?.closest('button, a, input, select, textarea, [role="button"], [data-no-drag]')) { + event.stopPropagation(); + } + } + + /** + * The expansion (nested sub-list) is never a drag surface for this row: swallow its pointer-downs so + * a drag can only start from the header row. Nested rows have their own drag surface and handle + * their own pointer-downs before the event reaches here. + */ + public onExpansionPointerDown(event: Event): void { + if (this.wholeRowDraggable) event.stopPropagation(); + } +} diff --git a/frontend/projects/valtimo/components/src/lib/components/left-sidebar/left-sidebar.component.ts b/frontend/projects/valtimo/components/src/lib/components/left-sidebar/left-sidebar.component.ts index eb12466260..aa099fc938 100644 --- a/frontend/projects/valtimo/components/src/lib/components/left-sidebar/left-sidebar.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/left-sidebar/left-sidebar.component.ts @@ -23,7 +23,7 @@ import { ViewChild, } from '@angular/core'; import {Router} from '@angular/router'; -import {MenuItem, ConfigService} from '@valtimo/shared'; +import {ConfigService, MenuItem} from '@valtimo/shared'; import {BehaviorSubject, combineLatest, Observable, Subscription} from 'rxjs'; import {take} from 'rxjs/operators'; @@ -112,6 +112,13 @@ export class LeftSidebarComponent implements AfterViewInit, OnDestroy { this.overflowMenuSequence$.next(''); if (!event.ctrlKey && !event.metaKey) { + // Custom links may point to an external/absolute URL, which the Angular router cannot + // resolve — open those in a new tab instead of attempting (and failing) an internal navigation. + if (this.isExternalLink(route)) { + window.open(route[0], '_blank', 'noopener'); + return; + } + this.router.navigate(route, {queryParams: {}}); combineLatest([ @@ -143,11 +150,22 @@ export class LeftSidebarComponent implements AfterViewInit, OnDestroy { } public openInNewTab(link: Array | undefined): void { + if (this.isExternalLink(link)) { + window.open(link![0], '_blank', 'noopener'); + return; + } + const url = this.router.serializeUrl(this.router.createUrlTree(link || ['/'])); window.open(url, '_blank'); } + /** A custom link whose first segment is an absolute/external URL (`http(s)://`, `//`, `mailto:`). */ + private isExternalLink(link: Array | undefined | null): boolean { + const first = link?.[0] ?? ''; + return /^(https?:)?\/\//i.test(first) || /^mailto:/i.test(first); + } + private openBreakpointSubscription(): void { this._breakpointSubscription = this.breakpointObserver .observe(['(max-width: 1055px)', '(min-width: 1056px)']) diff --git a/frontend/projects/valtimo/components/src/lib/components/mdi-icon-selector/mdi-icon-selector.component.html b/frontend/projects/valtimo/components/src/lib/components/mdi-icon-selector/mdi-icon-selector.component.html index 65f4b9ab10..d43fa64c67 100644 --- a/frontend/projects/valtimo/components/src/lib/components/mdi-icon-selector/mdi-icon-selector.component.html +++ b/frontend/projects/valtimo/components/src/lib/components/mdi-icon-selector/mdi-icon-selector.component.html @@ -1,5 +1,5 @@ + +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + +

+ {{ 'widgetTabManagement.content.externalPlugin.unavailableMessage' | translate }} +

+
+
diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss new file mode 100644 index 0000000000..3fcff75749 --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss @@ -0,0 +1,40 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.valtimo-widget-management-external-plugin { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-05); + + ::ng-deep .cds--text-input, + ::ng-deep .cds--list-box, + ::ng-deep .cds--list-box__field { + background-color: var(--cds-layer-02); + } + + &__title-container { + display: flex; + gap: var(--cds-spacing-05); + } + + &__title-input { + max-width: 300px; + } + + &__unavailable { + color: var(--cds-text-secondary); + } +} diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts new file mode 100644 index 0000000000..eb3383e154 --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + Inject, + OnDestroy, + OnInit, + Optional, + signal, +} from '@angular/core'; +import {AbstractControl, FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TranslateModule} from '@ngx-translate/core'; +import {InputLabelModule, MdiIconSelectorComponent} from '@valtimo/components'; +import { + ComboBoxModule, + DropdownModule, + InputModule, + LayerModule, + ListItem, +} from 'carbon-components-angular'; +import {Subscription} from 'rxjs'; +import { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetConfigOption, + ExternalPluginWidgetConfigProvider, +} from '../../../../constants'; +import {WidgetExternalPluginContent} from '../../../../models'; +import {WidgetWizardService} from '../../../../services'; + +@Component({ + templateUrl: './widget-management-external-plugin.component.html', + styleUrls: ['./widget-management-external-plugin.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TranslateModule, + InputModule, + ReactiveFormsModule, + ComboBoxModule, + DropdownModule, + LayerModule, + MdiIconSelectorComponent, + InputLabelModule, + ], +}) +export class WidgetManagementExternalPluginComponent implements OnInit, OnDestroy { + public readonly form = this.fb.group({ + widgetTitle: this.fb.control(this.widgetWizardService.$widgetTitle(), Validators.required), + widgetIcon: this.fb.control(this.widgetWizardService.$widgetIcon()), + }); + + public get widgetTitle(): AbstractControl | null { + return this.form.get('widgetTitle'); + } + + public get widgetIcon(): AbstractControl | null { + return this.form.get('widgetIcon'); + } + + public readonly $tokenAvailable = signal(!!this.configProvider); + + public readonly $configOptions = signal([]); + + private readonly _$selectedConfigId = signal(null); + private readonly _$selectedBundleKey = signal(null); + + public readonly $configItems = computed(() => + this.$configOptions().map(option => ({ + content: option.label, + configId: option.configId, + selected: option.configId === this._$selectedConfigId(), + })) + ); + + private readonly _$selectedConfig = computed( + () => + this.$configOptions().find(option => option.configId === this._$selectedConfigId()) ?? null + ); + + public readonly $showBundleSelect = computed( + () => (this._$selectedConfig()?.bundles.length ?? 0) > 1 + ); + + public readonly $bundleItems = computed(() => + (this._$selectedConfig()?.bundles ?? []).map(bundle => ({ + content: bundle.title, + bundleKey: bundle.key, + selected: bundle.key === this._$selectedBundleKey(), + })) + ); + + private readonly _subscriptions = new Subscription(); + + constructor( + @Optional() + @Inject(EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN) + private readonly configProvider: ExternalPluginWidgetConfigProvider, + private readonly fb: FormBuilder, + private readonly widgetWizardService: WidgetWizardService + ) { + effect(() => + this.widgetWizardService.$widgetContentValid.set( + !!this._$selectedConfigId() && (!this.$showBundleSelect() || !!this._$selectedBundleKey()) + ) + ); + } + + public ngOnInit(): void { + this.openTitleSubscription(); + this.openIconSubscription(); + this.loadConfigOptions(); + this.prefill(); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onConfigSelected(item: (ListItem & {configId?: string}) | null): void { + const configId = item?.configId ?? null; + this._$selectedConfigId.set(configId); + this._$selectedBundleKey.set(null); + + const config = this._$selectedConfig(); + // A single-bundle configuration needs no second choice — resolve it now (a null bundle key means + // the plugin ships one key-less bundle, which the resolver selects on its own). + if (config && config.bundles.length === 1) { + this._$selectedBundleKey.set(config.bundles[0].key); + } + this.updateContent(); + } + + public onBundleSelected(item: (ListItem & {bundleKey?: string | null}) | null): void { + this._$selectedBundleKey.set(item?.bundleKey ?? null); + this.updateContent(); + } + + private updateContent(): void { + const configurationId = this._$selectedConfigId(); + if (!configurationId) { + this.widgetWizardService.$widgetContent.set(null); + return; + } + const bundleKey = this._$selectedBundleKey(); + this.widgetWizardService.$widgetContent.set({ + configurationId, + ...(bundleKey ? {bundleKey} : {}), + } as WidgetExternalPluginContent); + } + + private loadConfigOptions(): void { + if (!this.configProvider) return; + this._subscriptions.add( + this.configProvider.getConfigOptions().subscribe(options => this.$configOptions.set(options)) + ); + } + + private openTitleSubscription(): void { + this._subscriptions.add( + this.widgetTitle?.valueChanges.subscribe(title => + this.widgetWizardService.$widgetTitle.set(title) + ) + ); + } + + private openIconSubscription(): void { + this._subscriptions.add( + this.widgetIcon?.valueChanges.subscribe(icon => + this.widgetWizardService.$widgetIcon.set(icon) + ) + ); + } + + private prefill(): void { + const content = this.widgetWizardService.$widgetContent() as WidgetExternalPluginContent | null; + if (!content?.configurationId) return; + this._$selectedConfigId.set(content.configurationId); + this._$selectedBundleKey.set(content.bundleKey ?? null); + } +} diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts index 46e1fb0315..ac585ba9c4 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts @@ -18,6 +18,7 @@ export * from './fields/widget-management-fields.component'; export * from './table/widget-management-table.component'; export * from './collection/widget-management-collection.component'; export * from './custom/widget-management-custom.component'; +export * from './external-plugin/widget-management-external-plugin.component'; export * from './map/widget-management-map.component'; export * from './person-card/widget-management-person-card.component'; export * from './metroline/widget-management-metroline.component'; diff --git a/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts b/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts new file mode 100644 index 0000000000..5d5a084fdc --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {InjectionToken} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** One `case-widget` bundle a plugin configuration exposes. */ +interface ExternalPluginWidgetBundleOption { + key: string | null; + title: string; +} + +/** An activated plugin configuration that exposes at least one `case-widget` bundle. */ +interface ExternalPluginWidgetConfigOption { + configId: string; + label: string; + bundles: ExternalPluginWidgetBundleOption[]; +} + +/** + * Supplies the external-plugin widget config editor with the selectable plugin configurations + + * their `case-widget` bundles. Implemented by an app that has `@valtimo/plugin` available (e.g. + * case-management), so `@valtimo/layout` needs no dependency on `@valtimo/plugin` — mirrors + * {@link CUSTOM_WIDGET_TOKEN}. When the token is absent the editor renders a disabled/empty state. + */ +interface ExternalPluginWidgetConfigProvider { + getConfigOptions(): Observable; +} + +const EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN = new InjectionToken( + 'Provides the selectable external-plugin configurations and their case-widget bundles.' +); + +export { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetBundleOption, + ExternalPluginWidgetConfigOption, + ExternalPluginWidgetConfigProvider, +}; diff --git a/frontend/projects/valtimo/layout/src/lib/constants/index.ts b/frontend/projects/valtimo/layout/src/lib/constants/index.ts index 88d67c5796..888df35ab7 100644 --- a/frontend/projects/valtimo/layout/src/lib/constants/index.ts +++ b/frontend/projects/valtimo/layout/src/lib/constants/index.ts @@ -15,6 +15,7 @@ */ export * from './custom-widget-token'; +export * from './external-plugin-widget-token'; export * from './layout.test-ids'; export * from './widget.constants'; export * from './widget-management.constants'; diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts index cfa7c5ad0d..46a6b623ff 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts @@ -86,6 +86,11 @@ interface WidgetCustomContent { componentValue: {[key: string]: string}; } +interface WidgetExternalPluginContent { + configurationId: string; + bundleKey?: string; +} + interface WidgetFormioContent { formDefinitionName: string; } @@ -218,6 +223,7 @@ type WidgetContentProperties = | WidgetTableContent | WidgetInteractiveTableContent | WidgetCustomContent + | WidgetExternalPluginContent | WidgetFormioContent | WidgetCollectionContent | WidgetMapContent @@ -231,6 +237,7 @@ type WidgetContentProperties = export { WidgetContentProperties, WidgetCustomContent, + WidgetExternalPluginContent, WidgetFieldsContent, WidgetFormioContent, WidgetTableContent, diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts index 3d81d4e0be..0a68547987 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts @@ -50,6 +50,7 @@ const WidgetTypeTags: Record = { [WidgetType.CUSTOM]: 'brown' as TagType, [WidgetType.IMAGE]: 'cool-gray', [WidgetType.DIVIDER]: 'outline', + [WidgetType.EXTERNAL_PLUGIN]: 'cool-gray' as TagType, }; export {WidgetManagementTab, WidgetTypeTags}; diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts index f0ae24d07b..d8e54671e8 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts @@ -19,12 +19,13 @@ import {Type} from '@angular/core'; import { WidgetManagementCollectionComponent, WidgetManagementCustomComponent, + WidgetManagementExternalPluginComponent, WidgetManagementFieldsComponent, WidgetManagementHighlightComponent, WidgetManagementImageComponent, WidgetManagementMapComponent, - WidgetManagementPersonCardComponent, WidgetManagementMetrolineComponent, + WidgetManagementPersonCardComponent, WidgetManagementTableComponent, WidgetManagementTextComponent, } from '../components/widget-management/management-content'; @@ -161,6 +162,13 @@ const AVAILABLE_WIDGETS: WidgetTypeSelection[] = [ type: WidgetType.IMAGE, component: WidgetManagementImageComponent, }, + { + titleKey: 'widgetTabManagement.type.external-plugin.title', + descriptionKey: 'widgetTabManagement.type.external-plugin.description', + illustrationUrl: 'valtimo-layout/img/widget-management/types/angular.svg', + type: WidgetType.EXTERNAL_PLUGIN, + component: WidgetManagementExternalPluginComponent, + }, { titleKey: 'widgetTabManagement.type.text.title', descriptionKey: 'widgetTabManagement.type.text.description', diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts index 9620bcb980..b6f8f0f676 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts @@ -19,13 +19,14 @@ import { WidgetCollectionContent, WidgetContentProperties, WidgetCustomContent, + WidgetExternalPluginContent, WidgetFieldsContent, WidgetHighlightContent, - WidgetInteractiveTableContent, WidgetImageContent, + WidgetInteractiveTableContent, WidgetMapContent, - WidgetPersonCardContent, WidgetMetrolineContent, + WidgetPersonCardContent, WidgetTableContent, WidgetTextContent, } from './widget-content.model'; @@ -44,6 +45,7 @@ enum WidgetType { HIGHLIGHT = 'highlight', PERSON_CARD = 'person-card', IMAGE = 'image', + EXTERNAL_PLUGIN = 'external-plugin', TEXT = 'text', } @@ -176,6 +178,11 @@ interface ImageWidget extends BasicWidget { properties: WidgetImageContent; } +interface ExternalPluginWidget extends BasicWidget { + type: WidgetType.EXTERNAL_PLUGIN; + properties: WidgetExternalPluginContent; +} + interface TextWidget extends BasicWidget { type: WidgetType.TEXT; properties: WidgetTextContent; @@ -194,6 +201,7 @@ type Widget = | MetrolineWidget | HighlightWidget | ImageWidget + | ExternalPluginWidget | TextWidget; type WidgetWithUuid = Widget & { @@ -267,10 +275,15 @@ type OptionalWidgets = | WidgetType.METROLINE | WidgetType.HIGHLIGHT | WidgetType.IMAGE + // Only the case surface renders this (as a sandboxed iframe); other surfaces (iko, the layout + // default) omit it, so it must be optional in the component map. + | WidgetType.EXTERNAL_PLUGIN | WidgetType.TEXT; -type WidgetComponentMap = - Record, Type> & +type WidgetComponentMap = Record< + Exclude, + Type +> & Partial>>; type WidgetContext = 'case' | 'iko'; @@ -297,6 +310,7 @@ export { CollectionWidget, CustomWidgetConfig, CustomWidget, + ExternalPluginWidget, TableWidget, InteractiveTableWidget, MapWidget, diff --git a/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts b/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts index 4e224f56ba..efd63e28c1 100644 --- a/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts +++ b/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts @@ -113,7 +113,12 @@ export class WidgetWizardService { const selectedType = this.$selectedWidget()?.type; return !selectedType ? false - : [WidgetType.COLLECTION, WidgetType.FIELDS, WidgetType.TABLE].includes(selectedType); + : [ + WidgetType.COLLECTION, + WidgetType.FIELDS, + WidgetType.TABLE, + WidgetType.EXTERNAL_PLUGIN, + ].includes(selectedType); }, }, [WidgetWizardStep.APPEARANCE]: { @@ -122,7 +127,12 @@ export class WidgetWizardService { const selectedType = this.$selectedWidget()?.type; return ( !!selectedType && - [WidgetType.FIELDS, WidgetType.COLLECTION, WidgetType.TABLE].includes(selectedType) + [ + WidgetType.FIELDS, + WidgetType.COLLECTION, + WidgetType.TABLE, + WidgetType.EXTERNAL_PLUGIN, + ].includes(selectedType) ); }, }, diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.html index 2734b2359b..0900b3931a 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.html +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.html @@ -1,5 +1,5 @@ - - - - - -
- - - - - - - - -
- - - - - - -
-
+ + +

+ {{ 'pluginManagement.add' | translate }} +

+ + +
+
+
+ +
+ +
+ + + + + + + + + +
+ +
+ +
+
+ + + + + + + + + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.scss index 966630baff..c164224b34 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.scss +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.scss @@ -1,5 +1,5 @@ /*! - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,30 @@ * limitations under the License. */ -.stepper-footer { +.plugin-add-modal__progress { width: 100%; + display: block; + padding-bottom: var(--cds-spacing-03); +} + +.add-plugin-modal-header { + margin-bottom: var(--cds-spacing-05); +} + +// Scoped to :host so the Carbon modal content override cannot leak to other modals. +:host .cds--modal-content { + overflow-x: hidden; +} + +.plugin-add-modal__compatibility-warning { + display: block; + margin-bottom: var(--cds-spacing-05); +} + +// Carbon caps inline notifications at a narrow max-width; stretch it to fill the modal so the full +// compatibility message reads on one block instead of a cramped column. +:host ::ng-deep .plugin-add-modal__compatibility-warning { + max-width: 100%; + width: 100%; + max-inline-size: unset; } diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.ts index 362ff17731..f66d801ca2 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-modal/plugin-add-modal.component.ts @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,27 @@ * limitations under the License. */ -import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Component, EventEmitter, Input, OnDestroy, Output, ViewChild} from '@angular/core'; import {PluginManagementStateService} from '../../services'; -import {take} from 'rxjs/operators'; -import {BehaviorSubject, Subject} from 'rxjs'; -import {PluginConfigurationData, PluginManagementService} from '@valtimo/plugin'; +import {map, take} from 'rxjs/operators'; +import {BehaviorSubject, combineLatest, Observable, Subscription} from 'rxjs'; +import { + ExternalPluginDefinition, + ExternalPluginGrantedEndpointEntry, + ExternalPluginGrantedEventEntry, + ExternalPluginEndpoint, + ExternalPluginService, + extractExternalDefinitionId, + isExternalPluginDefinitionIncompatible, + isExternalPluginKey, + PluginConfigurationData, + PluginManagementService, +} from '@valtimo/plugin'; +import {PluginExternalConfigureComponent} from '../plugin-external-configure/plugin-external-configure.component'; import {NGXLogger} from 'ngx-logger'; import {CARBON_CONSTANTS} from '@valtimo/components'; +import {TranslateService} from '@ngx-translate/core'; +import {buildExternalPluginCompatibilityMessage} from '../../utils'; @Component({ standalone: false, @@ -28,33 +42,110 @@ import {CARBON_CONSTANTS} from '@valtimo/components'; templateUrl: './plugin-add-modal.component.html', styleUrls: ['./plugin-add-modal.component.scss'], }) -export class PluginAddModalComponent { - @Input() open = false; +export class PluginAddModalComponent implements OnDestroy { + @Input() public open = false; + @Input() public set externalDefinitions(value: ExternalPluginDefinition[] | null) { + this._externalDefinitions = value; + this._externalDefinitions$.next(value ?? []); + } + public get externalDefinitions(): ExternalPluginDefinition[] | null { + return this._externalDefinitions; + } - @Output() closeModal: EventEmitter = new EventEmitter(); + @Output() public closeModal = new EventEmitter(); - public readonly inputDisabled$ = this.stateService.inputDisabled$; - public readonly selectedPluginDefinition$ = this.stateService.selectedPluginDefinition$; + public readonly inputDisabled$ = this._stateService.inputDisabled$; + public readonly selectedPluginDefinition$ = this._stateService.selectedPluginDefinition$; public readonly configurationValid$ = new BehaviorSubject(false); - public readonly returnToFirstStepSubject$ = new Subject(); + + private _externalDefinitions: ExternalPluginDefinition[] | null = null; + private readonly _externalDefinitions$ = new BehaviorSubject([]); + + public readonly isExternalPlugin$: Observable = this.selectedPluginDefinition$.pipe( + map(def => isExternalPluginKey(def?.key)) + ); + + /** + * The localized compatibility warning to show on the configure step, or null when the selected + * plugin is embedded, compatible, or not yet chosen. Recomputed on language change so the message + * stays localized. + */ + public readonly incompatibleWarning$: Observable = combineLatest([ + this.selectedPluginDefinition$, + this._externalDefinitions$, + this._translateService.stream('key'), + ]).pipe( + map(([selected, definitions]) => { + if (!selected || !isExternalPluginKey(selected.key)) return null; + const definition = definitions.find(d => d.id === extractExternalDefinitionId(selected.key)); + if (!isExternalPluginDefinitionIncompatible(definition)) return null; + return buildExternalPluginCompatibilityMessage(definition!, this._translateService); + }) + ); + + public readonly endpoints$ = new BehaviorSubject< + Array + >([]); + public readonly eventSubscriptions$ = new BehaviorSubject>([]); + public readonly capabilities$ = new BehaviorSubject>([]); + public readonly permissionsValid$ = new BehaviorSubject(false); + + public currentStepIndex = 0; + public isExternal = false; + public progressSteps: Array<{label: string}> = []; + + @ViewChild(PluginExternalConfigureComponent) + private _externalConfigureComponent: PluginExternalConfigureComponent | undefined; + + private readonly _subscriptions = new Subscription(); constructor( - private readonly stateService: PluginManagementStateService, - private readonly pluginManagementService: PluginManagementService, - private readonly logger: NGXLogger - ) {} + private readonly _stateService: PluginManagementStateService, + private readonly _pluginManagementService: PluginManagementService, + private readonly _externalPluginService: ExternalPluginService, + private readonly _logger: NGXLogger, + private readonly _translateService: TranslateService + ) { + this._buildProgressSteps(); + this._subscriptions.add( + this._translateService.onLangChange.subscribe(() => this._buildProgressSteps()) + ); + this._subscriptions.add( + this.isExternalPlugin$.subscribe(isExternal => { + this.isExternal = isExternal; + this._buildProgressSteps(); + }) + ); + } + + ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public goToNextStep(): void { + if (this.currentStepIndex < this.progressSteps.length - 1) { + this.currentStepIndex++; + } + } public complete(): void { - this.stateService.save(); + this._stateService.save(); } public hide(): void { this.closeModal.emit(); setTimeout(() => { - this.returnToFirstStep(); - this.stateService.enableInput(); - this.stateService.clear(); + this.currentStepIndex = 0; + this.isExternal = false; + this._stateService.enableInput(); + this._stateService.clear(); + this.configurationValid$.next(false); + this.endpoints$.next([]); + this.eventSubscriptions$.next([]); + this.capabilities$.next([]); + this.permissionsValid$.next(false); + this._buildProgressSteps(); }, CARBON_CONSTANTS.modalAnimationMs); } @@ -67,10 +158,10 @@ export class PluginAddModalComponent { delete pluginConfiguration['configurationId']; delete pluginConfiguration['configurationTitle']; - this.stateService.disableInput(); + this._stateService.disableInput(); - this.stateService.selectedPluginDefinition$.pipe(take(1)).subscribe(selectedDefinition => { - this.pluginManagementService + this._stateService.selectedPluginDefinition$.pipe(take(1)).subscribe(selectedDefinition => { + this._pluginManagementService .savePluginConfiguration({ id: configuration.configurationId, definitionKey: selectedDefinition.key, @@ -79,18 +170,108 @@ export class PluginAddModalComponent { }) .subscribe({ next: () => { - this.stateService.refresh(); + this._stateService.refresh(); this.hide(); }, error: () => { - this.logger.error('Something went wrong with saving the plugin configuration.'); - this.stateService.enableInput(); + this._logger.error('Something went wrong with saving the plugin configuration.'); + this._stateService.enableInput(); }, }); }); } - private returnToFirstStep(): void { - this.returnToFirstStepSubject$.next(true); + public onEndpointsResolved( + endpoints: Array + ): void { + this.endpoints$.next(endpoints); + this._recomputePermissionsValid(); + } + + public onEventSubscriptionsResolved(eventTypes: Array): void { + this.eventSubscriptions$.next(eventTypes); + this._recomputePermissionsValid(); + } + + public onPermissionsValid(valid: boolean): void { + this.permissionsValid$.next(valid); + } + + public onGrantedEndpointsChange(endpoints: Array): void { + this._externalConfigureComponent?.setGrantedEndpoints(endpoints); + } + + public onGrantedEventsChange(events: Array): void { + this._externalConfigureComponent?.setGrantedEvents(events); + } + + public onCapabilitiesResolved(caps: Array): void { + this.capabilities$.next(caps); + this._recomputePermissionsValid(); + } + + public onGrantedCapabilitiesChange(caps: Array): void { + this._externalConfigureComponent?.setGrantedCapabilities(caps); + } + + public onExternalSave(event: { + definitionId: string; + title: string; + properties: Record; + grantedEndpoints: Array; + grantedEvents: Array; + grantedCapabilities: Array; + }): void { + this._stateService.disableInput(); + + this._externalPluginService + .createConfiguration({ + definitionId: event.definitionId, + title: event.title, + properties: event.properties, + grantedEndpoints: event.grantedEndpoints, + grantedEvents: event.grantedEvents, + grantedCapabilities: event.grantedCapabilities, + }) + .subscribe({ + next: () => { + this._stateService.refresh(); + this.hide(); + }, + error: () => { + this._logger.error( + 'Something went wrong with saving the external plugin configuration.' + ); + this._stateService.enableInput(); + }, + }); + } + + /** + * Permissions step starts valid (no acknowledgement required) only when the plugin declared + * neither endpoints nor event subscriptions. The acknowledgement otherwise gates the step. + */ + private _recomputePermissionsValid(): void { + const hasNothing = this.endpoints$.value.length === 0 + && this.eventSubscriptions$.value.length === 0 + && this.capabilities$.value.length === 0; + if (hasNothing) { + this.permissionsValid$.next(true); + } + } + + private _buildProgressSteps(): void { + const steps = [ + {label: this._translateService.instant('pluginManagement.addSteps.step0')}, + {label: this._translateService.instant('pluginManagement.addSteps.step1')}, + ]; + + if (this.isExternal) { + steps.push({ + label: this._translateService.instant('pluginManagement.addSteps.step2'), + }); + } + + this.progressSteps = steps; } } diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.html index 97bc18777c..adb8a57196 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.html +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.html @@ -1,5 +1,5 @@ - +
- + + -
- {{ 'title' | pluginTranslate: pluginDefinition.key | async }} -
+
+ {{ 'title' | pluginTranslate: pluginDefinition.key | async }} +
-

- {{ 'description' | pluginTranslate: pluginDefinition.key | async }} -

+

+ {{ 'description' | pluginTranslate: pluginDefinition.key | async }} +

+
+ + + + + + + + +
+ {{ pluginDefinition.externalName }} +
+ +

+ {{ pluginDefinition.externalDescription }} +

+
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.scss index e8193000ff..af6b101540 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.scss +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.scss @@ -43,6 +43,13 @@ object-fit: contain; height: 60px; width: 100%; + + &--external { + display: flex; + align-items: center; + justify-content: center; + color: var(--cds-icon-secondary); + } } .plugin-definition__grid { @@ -71,6 +78,7 @@ grid-template-columns: repeat(3, 1fr); gap: 16px; width: calc(100% - 24px); + padding-bottom: var(--cds-spacing-09); } :host ::ng-deep .cds--tile--selectable { diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.ts index a6b378fc88..df124ea442 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-add-select/plugin-add-select.component.ts @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,22 @@ * limitations under the License. */ -import {Component, OnDestroy, OnInit} from '@angular/core'; -import {combineLatest, Subscription} from 'rxjs'; +import {Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {TranslateService} from '@ngx-translate/core'; +import {BehaviorSubject, combineLatest, distinctUntilChanged, Observable, Subscription} from 'rxjs'; +import {map} from 'rxjs/operators'; import {PluginManagementStateService} from '../../services'; -import {PluginDefinition, PluginManagementService, PLUGIN_CATALOG_TEST_IDS} from '@valtimo/plugin'; +import {UnifiedPluginDefinition} from '../../models'; +import { + ExternalPluginDefinition, + getExternalPluginDescription, + getExternalPluginDisplayName, + PluginDefinition, + PluginManagementService, + toExternalPluginKey, + PLUGIN_CATALOG_TEST_IDS, +} from '@valtimo/plugin'; +import {isEqual} from 'lodash'; @Component({ standalone: false, @@ -26,47 +38,84 @@ import {PluginDefinition, PluginManagementService, PLUGIN_CATALOG_TEST_IDS} from styleUrls: ['./plugin-add-select.component.scss'], }) export class PluginAddSelectComponent implements OnInit, OnDestroy { - public readonly selectedPluginDefinition$ = this.stateService.selectedPluginDefinition$; - public readonly disabled$ = this.stateService.inputDisabled$; - public readonly pluginDefinitionsWithLogos$ = this.stateService.pluginDefinitionsWithLogos$; + @Input() set externalDefinitions(value: ExternalPluginDefinition[] | null) { + this._externalDefs$.next(value ?? []); + } + + public readonly selectedPluginDefinition$ = this._stateService.selectedPluginDefinition$; + public readonly disabled$ = this._stateService.inputDisabled$; public readonly testIds = PLUGIN_CATALOG_TEST_IDS; - private refreshSubscription!: Subscription; + private readonly _externalDefs$ = new BehaviorSubject([]); + + public readonly allDefinitions$: Observable = + combineLatest([ + this._stateService.pluginDefinitionsWithLogos$, + this._externalDefs$, + this._translateService.stream('key'), + ]).pipe( + map(([embedded, external]) => { + if (!embedded) return undefined; + + const lang = this._translateService.currentLang; + const externalDefs: UnifiedPluginDefinition[] = external.map(def => ({ + key: toExternalPluginKey(def.id), + title: getExternalPluginDisplayName(def, lang), + description: getExternalPluginDescription(def, lang), + source: 'external', + externalDefinitionId: def.id, + externalName: getExternalPluginDisplayName(def, lang), + externalDescription: getExternalPluginDescription(def, lang), + externalLogoUrl: def.logoUrl, + })); + + return [ + ...embedded.map(d => ({...d, source: 'embedded'} as UnifiedPluginDefinition)), + ...externalDefs, + ]; + }), + distinctUntilChanged((prev, curr) => isEqual(prev, curr)) + ); + + private readonly _subscriptions = new Subscription(); constructor( - private readonly pluginManagementService: PluginManagementService, - private readonly stateService: PluginManagementStateService + private readonly _pluginManagementService: PluginManagementService, + private readonly _stateService: PluginManagementStateService, + private readonly _translateService: TranslateService ) {} public ngOnInit(): void { - this.openRefreshSubscription(); - this.getPluginDefinitions(); + this._openRefreshSubscription(); + this._getPluginDefinitions(); } public ngOnDestroy(): void { - this.refreshSubscription?.unsubscribe(); + this._subscriptions.unsubscribe(); } public selectPluginDefinition(event: {value: PluginDefinition}): void { - this.stateService.selectPluginDefinition(event.value); + this._stateService.selectPluginDefinition(event.value); } public deselectPluginDefinition(): void { - this.stateService.clearSelectedPluginDefinition(); + this._stateService.clearSelectedPluginDefinition(); } - private getPluginDefinitions(): void { - this.pluginManagementService.getPluginDefinitions().subscribe(pluginDefinitions => { - this.stateService.setPluginDefinitions(pluginDefinitions); + private _getPluginDefinitions(): void { + this._pluginManagementService.getPluginDefinitions().subscribe(pluginDefinitions => { + this._stateService.setPluginDefinitions(pluginDefinitions); }); } - private openRefreshSubscription(): void { - this.refreshSubscription = combineLatest([ - this.stateService.showModal$, - this.stateService.refresh$, - ]).subscribe(() => { - this.stateService.clearSelectedPluginDefinition(); - }); + private _openRefreshSubscription(): void { + this._subscriptions.add( + combineLatest([ + this._stateService.showModal$, + this._stateService.refresh$, + ]).subscribe(() => { + this._stateService.clearSelectedPluginDefinition(); + }) + ); } } diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.html new file mode 100644 index 0000000000..6bd5e747ea --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.scss new file mode 100644 index 0000000000..448fb7d974 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.scss @@ -0,0 +1,21 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.plugin-apps-page__spinner { + display: flex; + align-items: center; + margin-right: 8px; +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.ts new file mode 100644 index 0000000000..5a7a64e142 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-apps-page/plugin-apps-page.component.ts @@ -0,0 +1,303 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, OnDestroy} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ActionItem, CarbonListModule, CarbonTag, ColumnConfig, ConfirmationModalModule, ViewType} from '@valtimo/components'; +import { + ExternalPluginHost, + ExternalPluginHostCreateRequest, + ExternalPluginHostEventQueueUpdateRequest, + ExternalPluginHostUsage, + ExternalPluginService, +} from '@valtimo/plugin'; +import {ButtonModule, LoadingModule} from 'carbon-components-angular'; +import {BehaviorSubject, EMPTY, fromEvent, merge, Observable, of, Subject, timer} from 'rxjs'; +import {catchError, distinctUntilChanged, map, startWith, switchMap, take, takeUntil, tap} from 'rxjs/operators'; +import {isEqual} from 'lodash'; +import {NGXLogger} from 'ngx-logger'; +import {PluginHostModalComponent} from '../plugin-host-modal/plugin-host-modal.component'; +import {PluginHostEventQueueModalComponent} from '../plugin-host-event-queue-modal/plugin-host-event-queue-modal.component'; +import {PluginUsageModalComponent} from '../plugin-usage-modal/plugin-usage-modal.component'; + +@Component({ + standalone: true, + templateUrl: './plugin-apps-page.component.html', + styleUrls: ['./plugin-apps-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ButtonModule, + LoadingModule, + CarbonListModule, + ConfirmationModalModule, + PluginHostModalComponent, + PluginHostEventQueueModalComponent, + PluginUsageModalComponent, + ], +}) +export class PluginAppsPageComponent implements OnDestroy { + private readonly _destroy$ = new Subject(); + private readonly _refreshHosts$ = new Subject(); + private _hostsInitialLoad = true; + + private readonly _tabVisible$: Observable = fromEvent(document, 'visibilitychange').pipe( + startWith(null), + map(() => document.visibilityState === 'visible') + ); + + public readonly hostsLoading$ = new BehaviorSubject(true); + public readonly hostsRefreshing$ = new BehaviorSubject(false); + public readonly hostModalOpen$ = new BehaviorSubject(false); + public readonly reloadModalOpen$ = new BehaviorSubject(false); + public readonly deleteHostModalOpen$ = new BehaviorSubject(false); + public hostToDelete: ExternalPluginHost | null = null; + + public readonly eventQueueModalOpen$ = new BehaviorSubject(false); + public readonly hostToEditEventQueue$ = new BehaviorSubject(null); + + public readonly usageModalOpen$ = new BehaviorSubject(false); + public readonly usageModalUsages$ = new BehaviorSubject>([]); + public usageModalEntityName: string | null = null; + public usageModalTitleKey = ''; + public usageModalDescriptionKey = ''; + + public readonly hostFields: ColumnConfig[] = [ + { + key: 'name', + label: 'pluginManagement.labels.name', + viewType: ViewType.TEXT, + }, + { + key: 'baseUrl', + label: 'pluginManagement.labels.baseUrl', + viewType: ViewType.TEXT, + }, + { + key: 'statusTag', + label: 'pluginManagement.labels.status', + viewType: ViewType.TAGS, + }, + { + key: 'lastHealthCheckFormatted', + label: 'pluginManagement.labels.lastHealthCheck', + viewType: ViewType.TEXT, + }, + ]; + + public readonly hostActionItems: ActionItem[] = [ + { + callback: this.editHostEventQueue.bind(this), + label: 'pluginManagement.editEventQueue', + }, + { + callback: this.deleteHost.bind(this), + label: 'interface.delete', + type: 'danger', + }, + ]; + + public readonly hosts$: Observable< + Array + > = merge( + this._tabVisible$.pipe(switchMap(visible => (visible ? timer(0, 5000) : EMPTY))), + this._refreshHosts$ + ).pipe( + takeUntil(this._destroy$), + tap(() => { + if (!this._hostsInitialLoad) { + this.hostsRefreshing$.next(true); + } + }), + switchMap(() => + this._externalPluginService + .getHosts() + .pipe(catchError(() => of([] as ExternalPluginHost[]))) + ), + map(hosts => hosts.filter(h => h.kind === 'APP')), + switchMap(hosts => + this._translateService.stream('key').pipe( + map(() => + hosts.map(host => ({ + ...host, + statusTag: this._getStatusTag(host.status), + lastHealthCheckFormatted: this._formatLastHealthCheck(host.lastHealthCheck), + })) + ) + ) + ), + tap(() => { + this._hostsInitialLoad = false; + this.hostsLoading$.next(false); + this.hostsRefreshing$.next(false); + }), + distinctUntilChanged((prev, curr) => isEqual(prev, curr)) + ); + + constructor( + private readonly _logger: NGXLogger, + private readonly _translateService: TranslateService, + private readonly _externalPluginService: ExternalPluginService + ) {} + + public ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + public openHostModal(): void { + this.hostModalOpen$.next(true); + } + + public closeHostModal(): void { + this.hostModalOpen$.next(false); + } + + public submitHost(request: ExternalPluginHostCreateRequest): void { + this._externalPluginService.createHost(request).subscribe({ + next: () => { + this.hostModalOpen$.next(false); + this.reloadModalOpen$.next(true); + }, + error: () => { + this._logger.error('Something went wrong with creating the app.'); + }, + }); + } + + public deleteHost(host: ExternalPluginHost): void { + this._externalPluginService + .getHostUsages(host.id) + .pipe(take(1)) + .subscribe({ + next: usages => { + if (usages.length > 0) { + this._showHostInUseModal(host, usages); + return; + } + this.hostToDelete = host; + this.deleteHostModalOpen$.next(true); + }, + error: () => { + this.hostToDelete = host; + this.deleteHostModalOpen$.next(true); + }, + }); + } + + public confirmDeleteHost(): void { + if (!this.hostToDelete) return; + const host = this.hostToDelete; + this._externalPluginService + .deleteHost(host.id) + .pipe(take(1)) + .subscribe({ + next: () => { + this.hostToDelete = null; + this.hostsLoading$.next(true); + this._refreshHosts$.next(); + }, + error: (response: HttpErrorResponse) => { + if (response.status === 409 && response.error?.usages) { + this.hostToDelete = null; + this._showHostInUseModal(host, response.error.usages as Array); + return; + } + this._logger.error('Something went wrong with deleting the app.'); + }, + }); + } + + public cancelDeleteHost(): void { + this.hostToDelete = null; + } + + public editHostEventQueue(host: ExternalPluginHost): void { + this.hostToEditEventQueue$.next(host); + this.eventQueueModalOpen$.next(true); + } + + public closeEventQueueModal(): void { + this.eventQueueModalOpen$.next(false); + this.hostToEditEventQueue$.next(null); + } + + public submitEventQueueUpdate(request: ExternalPluginHostEventQueueUpdateRequest): void { + const host = this.hostToEditEventQueue$.value; + if (!host) return; + this._externalPluginService.updateHostEventQueue(host.id, request).subscribe({ + next: () => { + this.eventQueueModalOpen$.next(false); + this.hostToEditEventQueue$.next(null); + this._refreshHosts$.next(); + }, + error: () => { + this._logger.error('Something went wrong with updating the app event queue.'); + }, + }); + } + + public closeUsageModal(): void { + this.usageModalOpen$.next(false); + this.usageModalUsages$.next([]); + this.usageModalEntityName = null; + } + + public confirmReload(): void { + window.location.reload(); + } + + public cancelReload(): void { + this.hostsLoading$.next(true); + this._refreshHosts$.next(); + } + + private _showHostInUseModal( + host: ExternalPluginHost, + usages: Array + ): void { + this.usageModalEntityName = + host.name || this._translateService.instant('pluginManagement.hostInUseModal.thisHost'); + this.usageModalTitleKey = 'pluginManagement.hostInUseModal.title'; + this.usageModalDescriptionKey = 'pluginManagement.hostInUseModal.description'; + this.usageModalUsages$.next(usages); + this.usageModalOpen$.next(true); + } + + private _getStatusTag(status: 'CONNECTED' | 'UNREACHABLE'): CarbonTag { + return { + content: this._translateService.instant(`pluginManagement.hostStatus.${status}`), + type: status === 'CONNECTED' ? 'green' : 'red', + }; + } + + private _formatLastHealthCheck(lastHealthCheck: string | null): string { + if (!lastHealthCheck) { + return '-'; + } + const date = new Date(lastHealthCheck); + return date.toLocaleString(this._translateService.currentLang || 'en', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts index dcc0e7cb2a..465825f0aa 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-edit-modal/plugin-edit-modal.component.ts @@ -36,6 +36,8 @@ export class PluginEditModalComponent { @Input() public readonly saveNewConfiguration = false; @Output() closeModal: EventEmitter = new EventEmitter(); + @Output() deleteEvent: EventEmitter<{configurationId: string; configurationTitle: string}> = + new EventEmitter(); public readonly inputDisabled$ = this.stateService.inputDisabled$; public readonly selectedPluginConfiguration$: Observable = @@ -52,25 +54,19 @@ export class PluginEditModalComponent { this.stateService.saveEdit(); } + /** + * Bubble the delete request up to the parent so the shared usage pre-check + + * destructive-confirmation flow runs in one place (mirrors how the external edit modal works). + * The parent closes this modal as part of that flow. + */ public delete(): void { - this.stateService.delete(); - this.stateService.disableInput(); - this.stateService.selectedPluginConfiguration$ .pipe(take(1)) .subscribe(selectedPluginConfiguration => { - this.pluginManagementService - .deletePluginConfiguration(selectedPluginConfiguration.id) - .subscribe( - () => { - this.stateService.refresh(); - this.hide(); - }, - () => { - this.logger.error('Something went wrong with deleting the plugin configuration.'); - this.stateService.enableInput(); - } - ); + this.deleteEvent.emit({ + configurationId: selectedPluginConfiguration.id, + configurationTitle: selectedPluginConfiguration.title ?? '', + }); }); } diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.html new file mode 100644 index 0000000000..bee83cd92d --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.html @@ -0,0 +1,42 @@ + + + + + + + + + + +
+
+ + +
+ +
+ + +
+
+
+
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.scss new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.ts new file mode 100644 index 0000000000..d1b3837bab --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-configure/plugin-external-configure.component.ts @@ -0,0 +1,224 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, + signal, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TranslateModule} from '@ngx-translate/core'; +import {LoadingModule} from 'carbon-components-angular'; +import {Subscription} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import { + ExternalPluginDefinition, + ExternalPluginGrantedEndpointEntry, + ExternalPluginGrantedEventEntry, + ExternalPluginIframeComponent, + ExternalPluginEndpoint, + ExternalPluginService, + extractExternalDefinitionId, + isExternalPluginKey, +} from '@valtimo/plugin'; +import {PluginManagementStateService} from '../../services'; + +interface ExternalPluginSaveEvent { + definitionId: string; + title: string; + properties: Record; + grantedEndpoints: Array; + grantedEvents: Array; + grantedCapabilities: Array; +} + +@Component({ + standalone: true, + selector: 'valtimo-plugin-external-configure', + templateUrl: './plugin-external-configure.component.html', + styleUrls: ['./plugin-external-configure.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + LoadingModule, + ExternalPluginIframeComponent, + ], +}) +export class PluginExternalConfigureComponent implements OnInit, OnDestroy { + @Output() public validEvent = new EventEmitter(); + @Output() public saveEvent = new EventEmitter(); + @Output() public endpointsResolved = new EventEmitter>(); + @Output() public eventSubscriptionsResolved = new EventEmitter>(); + @Output() public capabilitiesResolved = new EventEmitter>(); + + public readonly $configBundleUrl = signal(null); + public readonly $loading = signal(true); + + public readonly _form = new FormGroup({ + title: new FormControl('', Validators.required), + properties: new FormControl('{}'), + }); + + private _definitionId: string | null = null; + private _iframeConfigTitle: string = ''; + private _iframeConfigData: Record | null = null; + private _grantedEndpoints: Array = []; + private _grantedEvents: Array = []; + private _grantedCapabilities: Array = []; + private readonly _subscriptions = new Subscription(); + + constructor( + private readonly _stateService: PluginManagementStateService, + private readonly _externalPluginService: ExternalPluginService + ) {} + + public ngOnInit(): void { + this._subscriptions.add( + this._stateService.selectedPluginDefinition$ + .pipe( + switchMap(def => { + if (!def?.key || !isExternalPluginKey(def.key)) { + this._definitionId = null; + this.$configBundleUrl.set(null); + this.$loading.set(false); + this.endpointsResolved.emit([]); + this.eventSubscriptionsResolved.emit([]); + this.capabilitiesResolved.emit([]); + return []; + } + + this._definitionId = extractExternalDefinitionId(def.key); + this.$loading.set(true); + + return this._externalPluginService.getDefinition(this._definitionId).pipe( + map((definition: ExternalPluginDefinition) => { + const configBundle = definition.manifest?.frontendBundles?.find( + b => b.type === 'config' + ); + + if (configBundle) { + this.$configBundleUrl.set( + `${definition.baseUrl}/${definition.version}${configBundle.path}` + ); + } else { + this.$configBundleUrl.set(null); + } + + const endpoints = definition.manifest?.permissions?.endpoints ?? []; + this.endpointsResolved.emit(endpoints); + const eventSubscriptions = definition.manifest?.eventSubscriptions ?? []; + this.eventSubscriptionsResolved.emit(eventSubscriptions); + const capabilities = definition.manifest?.permissions?.capabilities ?? []; + this.capabilitiesResolved.emit(capabilities); + + this.$loading.set(false); + }) + ); + }) + ) + .subscribe() + ); + + this._subscriptions.add(this._form.valueChanges.subscribe(() => this._validateForm())); + + this._subscriptions.add(this._stateService.save$.subscribe(() => this._onSaveTriggered())); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onIframeConfigurationChanged(event: { + valid: boolean; + title: string; + data: Record; + }): void { + this._iframeConfigTitle = event.title; + this._iframeConfigData = event.data; + this.validEvent.emit(event.valid); + } + + public setGrantedEndpoints(endpoints: Array): void { + this._grantedEndpoints = endpoints; + } + + public setGrantedEvents(events: Array): void { + this._grantedEvents = events; + } + + public setGrantedCapabilities(caps: Array): void { + this._grantedCapabilities = caps; + } + + private _validateForm(): void { + if (this.$configBundleUrl()) return; + + const titleValid = !!this._form.value.title?.trim(); + let jsonValid = true; + const props = this._form.value.properties?.trim(); + if (props) { + try { + JSON.parse(props); + } catch { + jsonValid = false; + } + } + this.validEvent.emit(titleValid && jsonValid); + } + + private _onSaveTriggered(): void { + if (!this._definitionId) return; + + if (this._iframeConfigData) { + this.saveEvent.emit({ + definitionId: this._definitionId, + title: this._iframeConfigTitle, + properties: this._iframeConfigData, + grantedEndpoints: this._grantedEndpoints, + grantedEvents: this._grantedEvents, + grantedCapabilities: this._grantedCapabilities, + }); + return; + } + + const title = this._form.value.title?.trim() ?? ''; + let properties: Record = {}; + const propsStr = this._form.value.properties?.trim(); + if (propsStr) { + try { + properties = JSON.parse(propsStr); + } catch { + return; + } + } + + this.saveEvent.emit({ + definitionId: this._definitionId, + title, + properties, + grantedEndpoints: this._grantedEndpoints, + grantedEvents: this._grantedEvents, + grantedCapabilities: this._grantedCapabilities, + }); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.html new file mode 100644 index 0000000000..380166d230 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.html @@ -0,0 +1,115 @@ + + + + + {{ configuration?.title }} - {{ $definitionName() }} + + + + +
+ + +
+ + + + + + +
+
+ + +
+ +
+ + + + +
+ {{ 'pluginManagement.invalidJson' | translate }} +
+
+
+
+
+
+ +
+ +
+
+ + + + + + + + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.scss new file mode 100644 index 0000000000..6cef089b17 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.scss @@ -0,0 +1,32 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host ::ng-deep .cds--modal-header { + display: flex; + flex-direction: column; +} + +.plugin-external-edit-modal__properties-textarea { + font-family: 'IBM Plex Mono', monospace; + font-size: 0.8125rem; + resize: vertical; +} + +.plugin-external-edit-modal__progress { + width: 100%; + margin-top: 16px; + margin-bottom: 8px; +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.ts new file mode 100644 index 0000000000..9e55e012f6 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-edit-modal/plugin-external-edit-modal.component.ts @@ -0,0 +1,393 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + signal, + SimpleChanges, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import { + ButtonModule, + LoadingModule, + ModalModule, + ProgressIndicatorModule, +} from 'carbon-components-angular'; +import {CARBON_CONSTANTS, ValtimoCdsModalDirective} from '@valtimo/components'; +import { + ExternalPluginDefinition, + ExternalPluginIframeComponent, + ExternalPluginEndpoint, + ExternalPluginService, + getExternalPluginDisplayName, +} from '@valtimo/plugin'; +import {UnifiedPluginConfigurationRow} from '../../models'; +import {forkJoin, Subscription} from 'rxjs'; +import {PluginExternalPermissionsComponent} from '../plugin-external-permissions/plugin-external-permissions.component'; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-external-edit-modal', + templateUrl: './plugin-external-edit-modal.component.html', + styleUrls: ['./plugin-external-edit-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + ModalModule, + ButtonModule, + LoadingModule, + ProgressIndicatorModule, + ValtimoCdsModalDirective, + ExternalPluginIframeComponent, + PluginExternalPermissionsComponent, + ], +}) +export class PluginExternalEditModalComponent implements OnChanges, OnDestroy { + @Input() public open = false; + @Input() public configuration: UnifiedPluginConfigurationRow | null = null; + + @Output() public closeEvent = new EventEmitter(); + @Output() public savedEvent = new EventEmitter(); + @Output() public deleteEvent = new EventEmitter(); + + public readonly _form = new FormGroup({ + title: new FormControl('', Validators.required), + properties: new FormControl('{}'), + }); + + public readonly $loading = signal(false); + public readonly $propertiesInvalid = signal(false); + public readonly $configBundleUrl = signal(null); + public readonly $prefillConfiguration = signal<{ + title: string; + configuration: Record; + } | null>(null); + + public readonly $endpoints = signal>([]); + public readonly $eventSubscriptions = signal>([]); + public readonly $capabilities = signal>([]); + public readonly $permissionsValid = signal(false); + public readonly $hasPermissionsStep = signal(false); + public readonly $definitionName = signal(''); + + public currentStepIndex = 0; + public progressSteps: Array<{label: string}> = []; + + private _iframeConfigTitle: string = ''; + private _iframeConfigData: Record | null = null; + private readonly _$configurationSchema = signal(null); + private readonly _$iframeValid = signal(false); + private readonly _$definition = signal(null); + + private readonly _subscriptions = new Subscription(); + private _propertiesValueSubscription: Subscription | null = null; + + constructor( + private readonly _externalPluginService: ExternalPluginService, + private readonly _translateService: TranslateService + ) { + this._buildProgressSteps(); + this._subscriptions.add( + this._translateService.onLangChange.subscribe(() => { + this._buildProgressSteps(); + this._updateDefinitionName(); + }) + ); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + this._propertiesValueSubscription?.unsubscribe(); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['open'] && this.open && this.configuration) { + this._initForm(); + } + } + + public goToNextStep(): void { + if (this.currentStepIndex < this.progressSteps.length - 1) { + this.currentStepIndex++; + } + } + + public onSave(): void { + if (!this.configuration?.id) return; + + if (this.$configBundleUrl()) { + this._saveFromIframe(); + return; + } + + if (this._form.invalid || this.$propertiesInvalid()) return; + + let properties: Record; + try { + properties = JSON.parse(this._form.value.properties ?? '{}'); + } catch { + this.$propertiesInvalid.set(true); + return; + } + + this.$loading.set(true); + + // Permissions are accepted at activation and are immutable afterwards, so they are not sent + // on update — the backend leaves the granted endpoints unchanged when they are omitted. + this._externalPluginService + .updateConfiguration(this.configuration.id, { + title: this._form.value.title ?? '', + properties, + }) + .subscribe({ + next: () => { + this.$loading.set(false); + this.savedEvent.emit(); + }, + error: () => { + this.$loading.set(false); + }, + }); + } + + public onDelete(): void { + if (!this.configuration?.id) return; + this.deleteEvent.emit(this.configuration.id); + } + + public onClose(): void { + this.closeEvent.emit(); + setTimeout(() => { + this._resetForm(); + }, CARBON_CONSTANTS.modalAnimationMs); + } + + public onIframeConfigurationChanged(event: { + valid: boolean; + title: string; + data: Record; + }): void { + this._iframeConfigTitle = event.title; + this._iframeConfigData = event.data; + this._$iframeValid.set(event.valid); + } + + public onPermissionsValid(valid: boolean): void { + this.$permissionsValid.set(valid); + } + + public get configValid(): boolean { + if (this.$configBundleUrl()) { + return this._$iframeValid(); + } + return this._form.valid && !this.$propertiesInvalid(); + } + + private _saveFromIframe(): void { + if (!this.configuration?.id) return; + + const prefill = this.$prefillConfiguration(); + const title = this._iframeConfigTitle || prefill?.title || this.configuration.title; + const properties = this._iframeConfigData ?? prefill?.configuration ?? {}; + + this.$loading.set(true); + + this._externalPluginService + .updateConfiguration(this.configuration.id, { + title, + properties, + }) + .subscribe({ + next: () => { + this.$loading.set(false); + this.savedEvent.emit(); + }, + error: () => { + this.$loading.set(false); + }, + }); + } + + private _initForm(): void { + this.currentStepIndex = 0; + this._form.reset({ + title: this.configuration?.title ?? '', + properties: '{}', + }); + this.$propertiesInvalid.set(false); + this._$configurationSchema.set(null); + this.$configBundleUrl.set(null); + this.$prefillConfiguration.set(null); + this._$iframeValid.set(false); + this._iframeConfigTitle = ''; + this._iframeConfigData = null; + this.$endpoints.set([]); + this.$eventSubscriptions.set([]); + this.$capabilities.set([]); + this.$permissionsValid.set(false); + this.$hasPermissionsStep.set(false); + this._$definition.set(null); + this.$definitionName.set(''); + + const configId = this.configuration?.id; + const definitionId = this.configuration?.externalDefinitionId; + + if (configId && definitionId) { + this.$loading.set(true); + forkJoin([ + this._externalPluginService.getConfiguration(configId), + this._externalPluginService.getDefinition(definitionId), + ]).subscribe({ + next: ([configDetail, definition]) => { + this._setDefinition(definition); + this._$configurationSchema.set(definition.configurationSchema); + this._resolveConfigBundleUrl(definition); + + if (this.$configBundleUrl()) { + this.$prefillConfiguration.set({ + title: configDetail.title, + configuration: configDetail.properties ?? {}, + }); + } else { + this._form.patchValue({ + title: configDetail.title, + properties: JSON.stringify(configDetail.properties ?? {}, null, 2), + }); + } + + const endpoints = definition.manifest?.permissions?.endpoints ?? []; + const eventSubscriptions = definition.manifest?.eventSubscriptions ?? []; + const capabilities = definition.manifest?.permissions?.capabilities ?? []; + this.$endpoints.set(endpoints); + this.$eventSubscriptions.set(eventSubscriptions); + this.$capabilities.set(capabilities); + this.$hasPermissionsStep.set( + endpoints.length > 0 || eventSubscriptions.length > 0 || capabilities.length > 0 + ); + this.$permissionsValid.set(true); + + this._buildProgressSteps(); + this.$loading.set(false); + }, + error: () => { + this.$loading.set(false); + }, + }); + } else if (definitionId) { + this.$loading.set(true); + this._externalPluginService.getDefinition(definitionId).subscribe({ + next: definition => { + this._setDefinition(definition); + this._$configurationSchema.set(definition.configurationSchema); + this._resolveConfigBundleUrl(definition); + + const endpoints = definition.manifest?.permissions?.endpoints ?? []; + const eventSubscriptions = definition.manifest?.eventSubscriptions ?? []; + const capabilities = definition.manifest?.permissions?.capabilities ?? []; + this.$endpoints.set(endpoints); + this.$eventSubscriptions.set(eventSubscriptions); + this.$capabilities.set(capabilities); + this.$hasPermissionsStep.set( + endpoints.length > 0 || eventSubscriptions.length > 0 || capabilities.length > 0 + ); + this.$permissionsValid.set(true); + + this._buildProgressSteps(); + this.$loading.set(false); + }, + error: () => { + this.$loading.set(false); + }, + }); + } else { + this._buildProgressSteps(); + } + + // `_initForm` runs on every modal open: tear down the previous subscription first so they do + // not accumulate over repeated opens. + this._propertiesValueSubscription?.unsubscribe(); + this._propertiesValueSubscription = + this._form.get('properties')?.valueChanges.subscribe(value => { + try { + JSON.parse(value ?? '{}'); + this.$propertiesInvalid.set(false); + } catch { + this.$propertiesInvalid.set(true); + } + }) ?? null; + } + + private _setDefinition(definition: ExternalPluginDefinition): void { + this._$definition.set(definition); + this._updateDefinitionName(); + } + + private _updateDefinitionName(): void { + const definition = this._$definition(); + this.$definitionName.set( + definition ? getExternalPluginDisplayName(definition, this._translateService.currentLang) : '' + ); + } + + private _resolveConfigBundleUrl(definition: ExternalPluginDefinition): void { + const configBundle = definition.manifest?.frontendBundles?.find(b => b.type === 'config'); + if (configBundle) { + this.$configBundleUrl.set(`${definition.baseUrl}/${definition.version}${configBundle.path}`); + } + } + + private _buildProgressSteps(): void { + const steps = [{label: this._translateService.instant('pluginManagement.editSteps.step0')}]; + + if (this.$hasPermissionsStep()) { + steps.push({ + label: this._translateService.instant('pluginManagement.editSteps.step1'), + }); + } + + this.progressSteps = steps; + } + + private _resetForm(): void { + this.currentStepIndex = 0; + this._form.reset({title: '', properties: '{}'}); + this.$propertiesInvalid.set(false); + this._$configurationSchema.set(null); + this.$configBundleUrl.set(null); + this.$prefillConfiguration.set(null); + this._$iframeValid.set(false); + this._iframeConfigTitle = ''; + this._iframeConfigData = null; + this.$endpoints.set([]); + this.$eventSubscriptions.set([]); + this.$capabilities.set([]); + this.$permissionsValid.set(false); + this.$hasPermissionsStep.set(false); + this._$definition.set(null); + this.$definitionName.set(''); + this._buildProgressSteps(); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.html new file mode 100644 index 0000000000..fa374492b9 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.html @@ -0,0 +1,145 @@ + + + + + + +
+
+ {{ 'pluginManagement.permissions.capabilitiesHeading' | translate }} +
+ + + + + {{ 'pluginManagement.permissions.capabilityHeading' | translate }} + + + {{ 'pluginManagement.permissions.descriptionHeading' | translate }} + + + + + + {{ cap }} + + + + {{ 'pluginManagement.permissions.capability.' + cap | translate }} + + + +
+ +
+
+ {{ 'pluginManagement.permissions.endpointsHeading' | translate }} +
+ + + + + {{ 'pluginManagement.permissions.methodHeading' | translate }} + + + {{ 'pluginManagement.permissions.endpointHeading' | translate }} + + + {{ 'pluginManagement.permissions.descriptionHeading' | translate }} + + + + + + {{ + endpoint.method | uppercase + }} + + + + {{ endpoint.pattern }} + + + + {{ endpoint.description }} + + + + + + +
+ +
+
+ {{ 'pluginManagement.permissions.eventsHeading' | translate }} +
+ + + + + {{ eventType }} + + + +
+ + + {{ 'pluginManagement.permissions.accept' | translate }} + diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.scss new file mode 100644 index 0000000000..ac1edb4d85 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.scss @@ -0,0 +1,55 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Layout-only rules. All visual treatment (typography, colors, borders, spacing values) flows +// from Carbon components and design tokens (`cds-*`, `cds--type-*`, `--cds-spacing-*`, +// `--cds-text-secondary`). No hardcoded sizes or colors live here. + +:host { + display: block; +} + +.plugin-external-permissions__section { + margin-block-end: var(--cds-spacing-06); +} + +.plugin-external-permissions__section-heading { + margin-block-end: var(--cds-spacing-03); +} + +.plugin-external-permissions__helper { + margin-block-start: var(--cds-spacing-02); + color: var(--cds-text-secondary); +} + +.plugin-external-permissions__acceptance { + display: block; + margin-block-start: var(--cds-spacing-05); +} + +:host ::ng-deep { + .cds--tag { + max-inline-size: unset !important; + } + + cds-inline-notification { + margin-bottom: 16px; + } + + .cds--inline-notification { + max-inline-size: unset !important; + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.ts new file mode 100644 index 0000000000..7f5f226e59 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-external-permissions/plugin-external-permissions.component.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + Input, + OnChanges, + Output, + signal, + SimpleChanges, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import { + CheckboxModule, + NotificationModule, + StructuredListModule, + TagModule, +} from 'carbon-components-angular'; +import { + ExternalPluginGrantedEndpointEntry, + ExternalPluginGrantedEventEntry, + ExternalPluginEndpoint, + ExternalPluginService, +} from '@valtimo/plugin'; +import {EnrichedEndpoint} from '../../models'; + +/** + * Lists the GZAC API endpoints and platform events an external plugin requires. Permissions are + * all-or-nothing: the backend rejects a configuration unless every endpoint declared in the + * manifest is granted **and** every declared `eventSubscriptions` type is granted. The admin + * reviews the full list and accepts both implications with a single acknowledgement before saving. + * + * In `readonlyMode` (editing an existing configuration) the list is informational only — the + * permissions were already accepted at activation — so no acknowledgement is required. + */ +@Component({ + standalone: true, + selector: 'valtimo-plugin-external-permissions', + templateUrl: './plugin-external-permissions.component.html', + styleUrls: ['./plugin-external-permissions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + CheckboxModule, + NotificationModule, + StructuredListModule, + TagModule, + ], +}) +export class PluginExternalPermissionsComponent implements OnChanges { + @Input() public endpoints: Array = []; + @Input() public eventSubscriptions: Array = []; + @Input() public capabilities: Array = []; + @Input() public readonlyMode = false; + + @Output() public validEvent = new EventEmitter(); + @Output() public grantedEndpointsChange = new EventEmitter< + Array + >(); + @Output() public grantedEventsChange = new EventEmitter>(); + @Output() public grantedCapabilitiesChange = new EventEmitter>(); + + public readonly $enrichedEndpoints = signal>([]); + public readonly $eventTypes = signal>([]); + public readonly $capabilities = signal>([]); + public readonly $accepted = signal(false); + + private readonly _externalPluginService = inject(ExternalPluginService); + private readonly _translateService = inject(TranslateService); + + public ngOnChanges(changes: SimpleChanges): void { + if ( + changes['endpoints'] || + changes['eventSubscriptions'] || + changes['capabilities'] || + changes['readonlyMode'] + ) { + this.$accepted.set(false); + this.$eventTypes.set([...this.eventSubscriptions]); + this.$capabilities.set([...this.capabilities]); + this._emitGrantedEvents(this.$eventTypes()); + this._emitGrantedCapabilities(this.$capabilities()); + this._fetchDescriptionsAndInit(); + } + } + + public onAcceptanceChange(accepted: boolean): void { + this.$accepted.set(accepted); + this._emitValidity(); + } + + public _httpMethodTagType(method: string): string { + switch (method.toUpperCase()) { + case 'GET': + return 'blue'; + case 'POST': + return 'green'; + case 'PUT': + return 'teal'; + case 'PATCH': + return 'cyan'; + case 'DELETE': + return 'red'; + default: + return 'warm-gray'; + } + } + + private _endpointKey(endpoint: ExternalPluginEndpoint): string { + return `${endpoint.method.toUpperCase()}:${endpoint.pattern}`; + } + + private _fetchDescriptionsAndInit(): void { + if (this.endpoints.length === 0) { + this._setEnriched([]); + return; + } + + const queries = this.endpoints.map(ep => ({method: ep.method, pattern: ep.pattern})); + const locale = this._translateService.currentLang || this._translateService.defaultLang || 'en'; + + this._externalPluginService.getEndpointDescriptions(queries, locale).subscribe({ + next: descriptions => { + const descriptionMap = new Map( + descriptions.map(d => [`${d.method.toUpperCase()}:${d.pattern}`, d.description]) + ); + + this._setEnriched( + this.endpoints.map(ep => ({ + ...ep, + description: descriptionMap.get(this._endpointKey(ep)) ?? null, + })) + ); + }, + error: () => { + this._setEnriched(this.endpoints.map(ep => ({...ep, description: null}))); + }, + }); + } + + private _setEnriched(enriched: Array): void { + this.$enrichedEndpoints.set(enriched); + this._emitGrantedEndpoints(enriched); + this._emitValidity(); + } + + /** + * Accepting the permissions grants the full declared set — partial grants are not allowed by the + * backend, so the component always emits every endpoint. + */ + private _emitGrantedEndpoints(enriched: Array): void { + this.grantedEndpointsChange.emit( + enriched.map(ep => ({method: ep.method.toUpperCase(), pattern: ep.pattern})) + ); + } + + /** + * Mirror of {@link _emitGrantedEndpoints} for event subscriptions — same all-or-nothing model: + * the host treats the granted list as the dispatch allowlist, narrower-or-equal to the manifest's + * declared `eventSubscriptions`. + */ + private _emitGrantedEvents(eventTypes: Array): void { + this.grantedEventsChange.emit(eventTypes.map(eventType => ({eventType}))); + } + + private _emitGrantedCapabilities(caps: Array): void { + this.grantedCapabilitiesChange.emit([...caps]); + } + + private _emitValidity(): void { + const empty = + this.$enrichedEndpoints().length === 0 && + this.$eventTypes().length === 0 && + this.$capabilities().length === 0; + const valid = this.readonlyMode || empty || this.$accepted(); + this.validEvent.emit(valid); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.html new file mode 100644 index 0000000000..b1dccf2b6e --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.html @@ -0,0 +1,65 @@ + + + + +

+ {{ 'pluginManagement.editHostEventQueueModalTitle' | translate: {name: host.name} }} +

+
+ +
+ + + + {{ 'pluginManagement.labels.eventQueueTtlMs' | translate }} + + {{ 'pluginManagement.hints.eventQueueTtlMs' | translate }} + +
+ + + + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.ts new file mode 100644 index 0000000000..7d1119dd0c --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-event-queue-modal/plugin-host-event-queue-modal.component.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule} from '@ngx-translate/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {ButtonModule, InputModule, LayerModule, ModalModule} from 'carbon-components-angular'; +import {SelectItem, SelectModule, ValtimoCdsModalDirective} from '@valtimo/components'; +import { + ExternalPluginEventQueueMode, + ExternalPluginHost, + ExternalPluginHostEventQueueUpdateRequest, + ExternalPluginService, +} from '@valtimo/plugin'; +import {Subscription} from 'rxjs'; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-host-event-queue-modal', + templateUrl: './plugin-host-event-queue-modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + ModalModule, + ButtonModule, + InputModule, + LayerModule, + SelectModule, + ValtimoCdsModalDirective, + ], +}) +export class PluginHostEventQueueModalComponent implements OnChanges, OnInit, OnDestroy { + @Input() public open = false; + @Input() public host: ExternalPluginHost | null = null; + + @Output() public closeEvent = new EventEmitter(); + @Output() public submitEvent = new EventEmitter(); + + public readonly form = new FormGroup({ + eventQueueMode: new FormControl('LIVE', {nonNullable: true}), + eventQueueTtlMs: new FormControl(null), + }); + + public minTtlMs = 60 * 60 * 1000; + public maxTtlMs = 30 * 24 * 60 * 60 * 1000; + public defaultTtlMs = 72 * 60 * 60 * 1000; + + public readonly queueModeItems: SelectItem[] = [ + {id: 'LIVE', translationKey: 'pluginManagement.eventQueueMode.live'}, + {id: 'DURABLE', translationKey: 'pluginManagement.eventQueueMode.durable'}, + ]; + + private readonly _subscriptions = new Subscription(); + + constructor(private readonly _externalPluginService: ExternalPluginService) {} + + public ngOnInit(): void { + this._subscriptions.add( + this.form.controls.eventQueueMode.valueChanges.subscribe(mode => { + const ttl = this.form.controls.eventQueueTtlMs; + if (mode === 'DURABLE') { + ttl.setValidators([ + Validators.required, + Validators.min(this.minTtlMs), + Validators.max(this.maxTtlMs), + ]); + if (ttl.value == null) ttl.setValue(this.defaultTtlMs); + } else { + ttl.clearValidators(); + ttl.setValue(null); + } + ttl.updateValueAndValidity(); + }) + ); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['open']?.currentValue === true) { + this._fetchDefaults(); + this._loadHost(); + } + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onSubmit(): void { + if (this.form.invalid) return; + const value = this.form.value; + const mode = value.eventQueueMode ?? 'LIVE'; + this.submitEvent.emit({ + eventQueueMode: mode, + eventQueueTtlMs: mode === 'DURABLE' ? value.eventQueueTtlMs ?? null : null, + }); + } + + public onClose(): void { + this.closeEvent.emit(); + } + + private _fetchDefaults(): void { + this._externalPluginService.getHostDefaults().subscribe(defaults => { + this.minTtlMs = defaults.minEventQueueTtlMs; + this.maxTtlMs = defaults.maxEventQueueTtlMs; + this.defaultTtlMs = defaults.defaultEventQueueTtlMs; + }); + } + + private _loadHost(): void { + if (!this.host) return; + this.form.reset( + { + eventQueueMode: this.host.eventQueueMode, + eventQueueTtlMs: this.host.eventQueueTtlMs, + }, + {emitEvent: false} + ); + // Trigger validator wiring for the current mode without losing the pre-loaded TTL. + const mode = this.host.eventQueueMode; + const ttl = this.form.controls.eventQueueTtlMs; + if (mode === 'DURABLE') { + ttl.setValidators([ + Validators.required, + Validators.min(this.minTtlMs), + Validators.max(this.maxTtlMs), + ]); + } else { + ttl.clearValidators(); + } + ttl.updateValueAndValidity(); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.html new file mode 100644 index 0000000000..9cb41a6b95 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.html @@ -0,0 +1,96 @@ + + + + +

+ {{ (isApp ? 'pluginManagement.addApp' : 'pluginManagement.addHost') | translate }} +

+
+ +
+ + {{ 'pluginManagement.labels.name' | translate }} + + + + + {{ 'pluginManagement.labels.baseUrl' | translate }} + + + + + {{ 'pluginManagement.labels.secret' | translate }} + + + + + {{ 'pluginManagement.labels.gzacCallbackBaseUrl' | translate }} + + + + + {{ 'pluginManagement.labels.eventBrokerAmqpUrl' | translate }} + + + + + {{ 'pluginManagement.labels.eventBrokerExchange' | translate }} + + + + + + + {{ 'pluginManagement.labels.eventQueueTtlMs' | translate }} + + {{ 'pluginManagement.hints.eventQueueTtlMs' | translate }} + +
+ + + + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.scss new file mode 100644 index 0000000000..267ee71cd4 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.scss @@ -0,0 +1,5 @@ +.plugin-host-modal__content { + display: flex; + flex-direction: column; + gap: 16px; +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.ts new file mode 100644 index 0000000000..0587f87509 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-host-modal/plugin-host-modal.component.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule} from '@ngx-translate/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {ButtonModule, InputModule, LayerModule, ModalModule} from 'carbon-components-angular'; +import {SelectItem, SelectModule, ValtimoCdsModalDirective} from '@valtimo/components'; +import { + ExternalPluginEventQueueMode, + ExternalPluginHostCreateRequest, + ExternalPluginHostKind, + ExternalPluginService, +} from '@valtimo/plugin'; +import {Subscription} from 'rxjs'; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-host-modal', + templateUrl: './plugin-host-modal.component.html', + styleUrls: ['./plugin-host-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + ModalModule, + ButtonModule, + InputModule, + LayerModule, + SelectModule, + ValtimoCdsModalDirective, + ], +}) +export class PluginHostModalComponent implements OnChanges, OnInit, OnDestroy { + @Input() public open = false; + @Input() public kind: ExternalPluginHostKind = 'PLUGIN_HOST'; + + @Output() public closeEvent = new EventEmitter(); + @Output() public submitEvent = new EventEmitter(); + + public get isApp(): boolean { + return this.kind === 'APP'; + } + + public readonly form = new FormGroup({ + name: new FormControl('', Validators.required), + baseUrl: new FormControl('', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]), + secret: new FormControl('', Validators.required), + gzacCallbackBaseUrl: new FormControl('', [ + Validators.required, + Validators.pattern(/^https?:\/\/.+/), + ]), + eventBrokerAmqpUrl: new FormControl(''), + eventBrokerExchange: new FormControl(''), + eventQueueMode: new FormControl('LIVE', {nonNullable: true}), + eventQueueTtlMs: new FormControl(null), + }); + + public minTtlMs = 60 * 60 * 1000; + public maxTtlMs = 30 * 24 * 60 * 60 * 1000; + public defaultTtlMs = 72 * 60 * 60 * 1000; + + public readonly queueModeItems: SelectItem[] = [ + {id: 'LIVE', translationKey: 'pluginManagement.eventQueueMode.live'}, + {id: 'DURABLE', translationKey: 'pluginManagement.eventQueueMode.durable'}, + ]; + + private readonly _subscriptions = new Subscription(); + + constructor(private readonly _externalPluginService: ExternalPluginService) {} + + public ngOnInit(): void { + this._subscriptions.add( + this.form.controls.eventQueueMode.valueChanges.subscribe(mode => { + const ttl = this.form.controls.eventQueueTtlMs; + if (mode === 'DURABLE') { + ttl.setValidators([ + Validators.required, + Validators.min(this.minTtlMs), + Validators.max(this.maxTtlMs), + ]); + if (ttl.value == null) ttl.setValue(this.defaultTtlMs); + } else { + ttl.clearValidators(); + ttl.setValue(null); + } + ttl.updateValueAndValidity(); + }) + ); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['open']?.currentValue === true) { + this._fetchDefaults(); + } + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onSubmit(): void { + if (this.form.invalid) return; + const value = this.form.value; + const mode = value.eventQueueMode ?? 'LIVE'; + this.submitEvent.emit({ + name: value.name!, + baseUrl: value.baseUrl!, + secret: value.secret!, + kind: this.kind, + gzacCallbackBaseUrl: value.gzacCallbackBaseUrl!, + eventBrokerAmqpUrl: value.eventBrokerAmqpUrl?.trim() || null, + eventBrokerExchange: value.eventBrokerExchange?.trim() || null, + eventQueueMode: mode, + eventQueueTtlMs: mode === 'DURABLE' ? value.eventQueueTtlMs ?? null : null, + }); + this._resetForm(); + } + + public onClose(): void { + this.closeEvent.emit(); + this._resetForm(); + } + + private _fetchDefaults(): void { + this._externalPluginService.getHostDefaults().subscribe(defaults => { + this.minTtlMs = defaults.minEventQueueTtlMs; + this.maxTtlMs = defaults.maxEventQueueTtlMs; + this.defaultTtlMs = defaults.defaultEventQueueTtlMs; + this.form.patchValue({ + gzacCallbackBaseUrl: defaults.gzacCallbackBaseUrl, + eventBrokerAmqpUrl: defaults.eventBrokerAmqpUrl, + eventBrokerExchange: defaults.eventBrokerExchange, + }); + }); + } + + private _resetForm(): void { + this.form.reset({ + name: '', + baseUrl: '', + secret: '', + gzacCallbackBaseUrl: '', + eventBrokerAmqpUrl: '', + eventBrokerExchange: '', + eventQueueMode: 'LIVE', + eventQueueTtlMs: null, + }); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.html new file mode 100644 index 0000000000..36e7c8c2cc --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.scss new file mode 100644 index 0000000000..6a7ee62f86 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.scss @@ -0,0 +1,21 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.plugin-hosts-page__spinner { + display: flex; + align-items: center; + margin-right: 8px; +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.ts new file mode 100644 index 0000000000..0fe818b567 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-hosts-page/plugin-hosts-page.component.ts @@ -0,0 +1,303 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, OnDestroy} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ActionItem, CarbonListModule, CarbonTag, ColumnConfig, ConfirmationModalModule, ViewType} from '@valtimo/components'; +import { + ExternalPluginHost, + ExternalPluginHostCreateRequest, + ExternalPluginHostEventQueueUpdateRequest, + ExternalPluginHostUsage, + ExternalPluginService, +} from '@valtimo/plugin'; +import {ButtonModule, LoadingModule} from 'carbon-components-angular'; +import {BehaviorSubject, EMPTY, fromEvent, merge, Observable, of, Subject, timer} from 'rxjs'; +import {catchError, distinctUntilChanged, map, startWith, switchMap, take, takeUntil, tap} from 'rxjs/operators'; +import {isEqual} from 'lodash'; +import {NGXLogger} from 'ngx-logger'; +import {PluginHostModalComponent} from '../plugin-host-modal/plugin-host-modal.component'; +import {PluginHostEventQueueModalComponent} from '../plugin-host-event-queue-modal/plugin-host-event-queue-modal.component'; +import {PluginUsageModalComponent} from '../plugin-usage-modal/plugin-usage-modal.component'; + +@Component({ + standalone: true, + templateUrl: './plugin-hosts-page.component.html', + styleUrls: ['./plugin-hosts-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ButtonModule, + LoadingModule, + CarbonListModule, + ConfirmationModalModule, + PluginHostModalComponent, + PluginHostEventQueueModalComponent, + PluginUsageModalComponent, + ], +}) +export class PluginHostsPageComponent implements OnDestroy { + private readonly _destroy$ = new Subject(); + private readonly _refreshHosts$ = new Subject(); + private _hostsInitialLoad = true; + + private readonly _tabVisible$: Observable = fromEvent(document, 'visibilitychange').pipe( + startWith(null), + map(() => document.visibilityState === 'visible') + ); + + public readonly hostsLoading$ = new BehaviorSubject(true); + public readonly hostsRefreshing$ = new BehaviorSubject(false); + public readonly hostModalOpen$ = new BehaviorSubject(false); + public readonly reloadModalOpen$ = new BehaviorSubject(false); + public readonly deleteHostModalOpen$ = new BehaviorSubject(false); + public hostToDelete: ExternalPluginHost | null = null; + + public readonly eventQueueModalOpen$ = new BehaviorSubject(false); + public readonly hostToEditEventQueue$ = new BehaviorSubject(null); + + public readonly usageModalOpen$ = new BehaviorSubject(false); + public readonly usageModalUsages$ = new BehaviorSubject>([]); + public usageModalEntityName: string | null = null; + public usageModalTitleKey = ''; + public usageModalDescriptionKey = ''; + + public readonly hostFields: ColumnConfig[] = [ + { + key: 'name', + label: 'pluginManagement.labels.name', + viewType: ViewType.TEXT, + }, + { + key: 'baseUrl', + label: 'pluginManagement.labels.baseUrl', + viewType: ViewType.TEXT, + }, + { + key: 'statusTag', + label: 'pluginManagement.labels.status', + viewType: ViewType.TAGS, + }, + { + key: 'lastHealthCheckFormatted', + label: 'pluginManagement.labels.lastHealthCheck', + viewType: ViewType.TEXT, + }, + ]; + + public readonly hostActionItems: ActionItem[] = [ + { + callback: this.editHostEventQueue.bind(this), + label: 'pluginManagement.editEventQueue', + }, + { + callback: this.deleteHost.bind(this), + label: 'interface.delete', + type: 'danger', + }, + ]; + + public readonly hosts$: Observable< + Array + > = merge( + this._tabVisible$.pipe(switchMap(visible => (visible ? timer(0, 5000) : EMPTY))), + this._refreshHosts$ + ).pipe( + takeUntil(this._destroy$), + tap(() => { + if (!this._hostsInitialLoad) { + this.hostsRefreshing$.next(true); + } + }), + switchMap(() => + this._externalPluginService + .getHosts() + .pipe(catchError(() => of([] as ExternalPluginHost[]))) + ), + map(hosts => hosts.filter(h => h.kind === 'PLUGIN_HOST')), + switchMap(hosts => + this._translateService.stream('key').pipe( + map(() => + hosts.map(host => ({ + ...host, + statusTag: this._getStatusTag(host.status), + lastHealthCheckFormatted: this._formatLastHealthCheck(host.lastHealthCheck), + })) + ) + ) + ), + tap(() => { + this._hostsInitialLoad = false; + this.hostsLoading$.next(false); + this.hostsRefreshing$.next(false); + }), + distinctUntilChanged((prev, curr) => isEqual(prev, curr)) + ); + + constructor( + private readonly _logger: NGXLogger, + private readonly _translateService: TranslateService, + private readonly _externalPluginService: ExternalPluginService + ) {} + + public ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + public openHostModal(): void { + this.hostModalOpen$.next(true); + } + + public closeHostModal(): void { + this.hostModalOpen$.next(false); + } + + public submitHost(request: ExternalPluginHostCreateRequest): void { + this._externalPluginService.createHost(request).subscribe({ + next: () => { + this.hostModalOpen$.next(false); + this.reloadModalOpen$.next(true); + }, + error: () => { + this._logger.error('Something went wrong with creating the plugin host.'); + }, + }); + } + + public deleteHost(host: ExternalPluginHost): void { + this._externalPluginService + .getHostUsages(host.id) + .pipe(take(1)) + .subscribe({ + next: usages => { + if (usages.length > 0) { + this._showHostInUseModal(host, usages); + return; + } + this.hostToDelete = host; + this.deleteHostModalOpen$.next(true); + }, + error: () => { + this.hostToDelete = host; + this.deleteHostModalOpen$.next(true); + }, + }); + } + + public confirmDeleteHost(): void { + if (!this.hostToDelete) return; + const host = this.hostToDelete; + this._externalPluginService + .deleteHost(host.id) + .pipe(take(1)) + .subscribe({ + next: () => { + this.hostToDelete = null; + this.hostsLoading$.next(true); + this._refreshHosts$.next(); + }, + error: (response: HttpErrorResponse) => { + if (response.status === 409 && response.error?.usages) { + this.hostToDelete = null; + this._showHostInUseModal(host, response.error.usages as Array); + return; + } + this._logger.error('Something went wrong with deleting the plugin host.'); + }, + }); + } + + public cancelDeleteHost(): void { + this.hostToDelete = null; + } + + public editHostEventQueue(host: ExternalPluginHost): void { + this.hostToEditEventQueue$.next(host); + this.eventQueueModalOpen$.next(true); + } + + public closeEventQueueModal(): void { + this.eventQueueModalOpen$.next(false); + this.hostToEditEventQueue$.next(null); + } + + public submitEventQueueUpdate(request: ExternalPluginHostEventQueueUpdateRequest): void { + const host = this.hostToEditEventQueue$.value; + if (!host) return; + this._externalPluginService.updateHostEventQueue(host.id, request).subscribe({ + next: () => { + this.eventQueueModalOpen$.next(false); + this.hostToEditEventQueue$.next(null); + this._refreshHosts$.next(); + }, + error: () => { + this._logger.error('Something went wrong with updating the plugin host event queue.'); + }, + }); + } + + public closeUsageModal(): void { + this.usageModalOpen$.next(false); + this.usageModalUsages$.next([]); + this.usageModalEntityName = null; + } + + public confirmReload(): void { + window.location.reload(); + } + + public cancelReload(): void { + this.hostsLoading$.next(true); + this._refreshHosts$.next(); + } + + private _showHostInUseModal( + host: ExternalPluginHost, + usages: Array + ): void { + this.usageModalEntityName = + host.name || this._translateService.instant('pluginManagement.hostInUseModal.thisHost'); + this.usageModalTitleKey = 'pluginManagement.hostInUseModal.title'; + this.usageModalDescriptionKey = 'pluginManagement.hostInUseModal.description'; + this.usageModalUsages$.next(usages); + this.usageModalOpen$.next(true); + } + + private _getStatusTag(status: 'CONNECTED' | 'UNREACHABLE'): CarbonTag { + return { + content: this._translateService.instant(`pluginManagement.hostStatus.${status}`), + type: status === 'CONNECTED' ? 'green' : 'red', + }; + } + + private _formatLastHealthCheck(lastHealthCheck: string | null): string { + if (!lastHealthCheck) { + return '-'; + } + const date = new Date(lastHealthCheck); + return date.toLocaleString(this._translateService.currentLang || 'en', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.html new file mode 100644 index 0000000000..34f14a4d29 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.html @@ -0,0 +1,121 @@ + + + + + {{ 'pluginManagement.logs.title' | translate }} — {{ configurationTitle }} + + +
+
+ + + + + + + +
+ +
+
+ + + +
+ + @if ($selectedRow(); as row) { + + } +
+
+ + + + +
+ + + {{ data.item.createdAt | date: 'dd-MM HH:mm:ss' }} + + + + {{ data.item.level }} + + + + {{ data.item.source }} + diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.scss new file mode 100644 index 0000000000..88d0468b1a --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.scss @@ -0,0 +1,100 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.plugin-log-modal { + &__filters { + display: flex; + gap: var(--cds-spacing-05); + margin-bottom: var(--cds-spacing-05); + } + + &__filter { + // Fixed dropdown width; no Carbon spacing token applies to component widths. + width: 180px; + } + + &__layout { + display: flex; + gap: 0; + align-items: stretch; + } + + &__list { + flex: 1; + min-width: 0; + + &--with-detail { + flex: 3; + } + } + + &__detail { + flex: 2; + // Minimum readable width for the detail pane; no Carbon spacing token applies. + min-width: 280px; + border-left: 1px solid var(--cds-border-subtle); + padding: var(--cds-spacing-05); + overflow-y: auto; + } + + &__detail-header { + display: flex; + align-items: center; + // 6px sits between spacing-02 (4px) and spacing-03 (8px); no token matches. + gap: 0.375rem; + margin-bottom: var(--cds-spacing-05); + } + + &__detail-timestamp { + font-size: 0.75rem; + color: var(--cds-text-secondary); + margin-left: var(--cds-spacing-02); + } + + &__close-btn { + margin-left: auto; + } + + &__detail-label { + color: var(--cds-text-secondary); + margin-bottom: var(--cds-spacing-02); + } + + &__detail-message { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.875rem; + margin: 0 0 var(--cds-spacing-05); + background: var(--cds-layer); + padding: var(--cds-spacing-04); + // One-off rounding of the message block; no Carbon token applies. + border-radius: 4px; + } + + &__detail-json { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.75rem; + margin: 0; + background: var(--cds-layer); + padding: var(--cds-spacing-04); + // One-off rounding of the JSON block; no Carbon token applies. + border-radius: 4px; + // Cap the JSON block height so long payloads scroll; no Carbon token applies. + max-height: 200px; + overflow-y: auto; + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.ts new file mode 100644 index 0000000000..c32934b026 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-log-modal/plugin-log-modal.component.ts @@ -0,0 +1,303 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + OnChanges, + Output, + signal, + SimpleChanges, + TemplateRef, + ViewChild, +} from '@angular/core'; +import {CommonModule, DatePipe} from '@angular/common'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import { + ButtonModule, + DropdownModule, + IconModule, + IconService, + LayerModule, + ModalModule, + TagModule, +} from 'carbon-components-angular'; +import {Close16} from '@carbon/icons'; +import { + CarbonListModule, + CarbonPaginatorConfig, + ColumnConfig, + Pagination, + ValtimoCdsModalDirective, + ViewType, +} from '@valtimo/components'; +import {ExternalPluginService, PluginLogEntry} from '@valtimo/plugin'; +import {ListItem} from 'carbon-components-angular'; + +const DEFAULT_PAGE_SIZE = 10; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-log-modal', + templateUrl: './plugin-log-modal.component.html', + styleUrls: ['./plugin-log-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ModalModule, + ButtonModule, + DropdownModule, + LayerModule, + TagModule, + CarbonListModule, + IconModule, + ValtimoCdsModalDirective, + DatePipe, + ], +}) +export class PluginLogModalComponent implements OnChanges { + @Input() public open = false; + @Input() public configurationId: string | null = null; + @Input() public configurationTitle: string = ''; + + @Output() public closeModal = new EventEmitter(); + + @ViewChild('timestampTpl', {static: true}) public timestampTpl!: TemplateRef; + @ViewChild('levelTpl', {static: true}) public levelTpl!: TemplateRef; + @ViewChild('sourceTpl', {static: true}) public sourceTpl!: TemplateRef; + + public readonly $loading = signal(false); + public readonly $rows = signal([]); + public readonly $selectedRow = signal(null); + private readonly _$totalElements = signal(0); + + public fields: ColumnConfig[] = []; + public page = 1; + public pageSize = DEFAULT_PAGE_SIZE; + + public readonly paginatorConfig: CarbonPaginatorConfig = { + itemsPerPageOptions: [DEFAULT_PAGE_SIZE], + showPageInput: false, + }; + + public levelItems: Partial[] = []; + public sourceItems: Partial[] = []; + + private _levelFilter = ''; + private _sourceFilter = ''; + + private readonly _externalPluginService = inject(ExternalPluginService); + private readonly _iconService = inject(IconService); + private readonly _cdr = inject(ChangeDetectorRef); + private readonly _translateService = inject(TranslateService); + + constructor() { + this._iconService.register(Close16); + } + + public get pagination(): Pagination { + return { + page: this.page, + size: this.pageSize, + collectionSize: this._$totalElements(), + }; + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['open'] && this.open && this.configurationId) { + this._initFields(); + this._resetFilters(); + this.$selectedRow.set(null); + this._loadLogs(); + } + } + + public onClose(): void { + this.closeModal.emit(); + } + + public onPaginationClicked(page: number): void { + this.page = page; + this._loadLogs(); + } + + public onRowClicked(row: PluginLogEntry): void { + this.$selectedRow.set(row); + this._cdr.markForCheck(); + } + + public onLevelSelected(event: {item: {value: string}}): void { + this._levelFilter = event?.item?.value ?? ''; + this.page = 1; + this._loadLogs(); + } + + public onSourceSelected(event: {item: {value: string}}): void { + this._sourceFilter = event?.item?.value ?? ''; + this.page = 1; + this._loadLogs(); + } + + public closeDetail(): void { + this.$selectedRow.set(null); + } + + public levelTagType(level: string): string { + switch (level) { + case 'info': + return 'blue'; + case 'warn': + return 'warm-gray'; + case 'error': + return 'red'; + case 'debug': + return 'cool-gray'; + default: + return 'warm-gray'; + } + } + + public sourceTagType(source: string): string { + switch (source) { + case 'plugin': + return 'purple'; + case 'gzac_api': + return 'teal'; + case 'http_request': + return 'cyan'; + default: + return 'warm-gray'; + } + } + + public formatData(data: unknown): string { + if (!data) return ''; + return JSON.stringify(data, null, 2); + } + + private _resetFilters(): void { + this._levelFilter = ''; + this._sourceFilter = ''; + this.page = 1; + this.pageSize = DEFAULT_PAGE_SIZE; + + // Built with `instant` since the items are rebuilt on every modal open, mirroring how other + // list items in this library are translated. + this.levelItems = [ + { + content: this._translateService.instant('pluginManagement.logs.filterAll'), + selected: true, + value: '', + }, + { + content: this._translateService.instant('pluginManagement.logs.levels.info'), + selected: false, + value: 'info', + }, + { + content: this._translateService.instant('pluginManagement.logs.levels.warn'), + selected: false, + value: 'warn', + }, + { + content: this._translateService.instant('pluginManagement.logs.levels.error'), + selected: false, + value: 'error', + }, + { + content: this._translateService.instant('pluginManagement.logs.levels.debug'), + selected: false, + value: 'debug', + }, + ]; + + this.sourceItems = [ + { + content: this._translateService.instant('pluginManagement.logs.filterAll'), + selected: true, + value: '', + }, + { + content: this._translateService.instant('pluginManagement.logs.sources.plugin'), + selected: false, + value: 'plugin', + }, + { + content: this._translateService.instant('pluginManagement.logs.sources.gzacApi'), + selected: false, + value: 'gzac_api', + }, + { + content: this._translateService.instant('pluginManagement.logs.sources.httpRequest'), + selected: false, + value: 'http_request', + }, + ]; + } + + private _initFields(): void { + this.fields = [ + { + key: 'createdAt', + label: 'pluginManagement.logs.columns.timestamp', + viewType: ViewType.TEMPLATE, + template: this.timestampTpl, + }, + { + key: 'level', + label: 'pluginManagement.logs.columns.level', + viewType: ViewType.TEMPLATE, + template: this.levelTpl, + }, + { + key: 'source', + label: 'pluginManagement.logs.columns.source', + viewType: ViewType.TEMPLATE, + template: this.sourceTpl, + }, + {key: 'message', label: 'pluginManagement.logs.columns.message', viewType: ViewType.TEXT}, + ]; + } + + private _loadLogs(): void { + if (!this.configurationId) return; + this.$loading.set(true); + this._externalPluginService + .getConfigurationLogs(this.configurationId, { + page: this.page - 1, + size: this.pageSize, + level: this._levelFilter || undefined, + source: this._sourceFilter || undefined, + }) + .subscribe({ + next: result => { + this.$rows.set(result.content); + this._$totalElements.set(result.totalElements); + this.$loading.set(false); + }, + error: () => { + this.$rows.set([]); + this._$totalElements.set(0); + this.$loading.set(false); + }, + }); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-management/plugin-management.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-management/plugin-management.component.html index ea2e6c5372..41b521a335 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-management/plugin-management.component.html +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-management/plugin-management.component.html @@ -1,5 +1,5 @@ + - + + + + + + + - + + + + + + + + + + + + +

+ {{ 'pluginManagement.upload.overwriteTitle' | translate }} +

+
+ +
+ + + +
+ + + + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.scss new file mode 100644 index 0000000000..ef8246e60c --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.scss @@ -0,0 +1,18 @@ +:host { + display: block; +} + +.plugin-upload-modal__content, +.plugin-upload-modal__overwrite-content { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-05); + + // The modal content is a definite-height scroll area (`.cds--modal-content`, overflow-y auto). + // Without this, overflowing content makes the flex column *shrink* its children instead of + // scrolling — the inline notification gets squashed to its 48px minimum while its text paints + // outside the box. Children keep their natural height; the section scrolls. + > * { + flex-shrink: 0; + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.ts new file mode 100644 index 0000000000..f0f96cf662 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-upload-modal/plugin-upload-modal.component.ts @@ -0,0 +1,305 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnChanges, + Output, + signal, + SimpleChanges, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {HttpErrorResponse} from '@angular/common/http'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import { + ButtonModule, + DropdownModule, + FileUploaderModule, + LayerModule, + ListItem, + LoadingModule, + ModalModule, + NotificationContent, + NotificationModule, +} from 'carbon-components-angular'; +import {ConfirmationModalModule, ValtimoCdsModalDirective} from '@valtimo/components'; +import {ExternalPluginEndpoint, ExternalPluginHost, ExternalPluginService} from '@valtimo/plugin'; +import {BehaviorSubject} from 'rxjs'; +import {buildExternalPluginCompatibilityMessage} from '../../utils'; +import {PluginExternalPermissionsComponent} from '../plugin-external-permissions/plugin-external-permissions.component'; + +/** + * State of the overwrite-review dialog: what the already-existing version is, the permissions the + * uploaded package requests (shown for re-review) and the pre-built warning notification. + */ +interface OverwriteReview { + pluginId: string; + version: string; + endpoints: Array; + eventSubscriptions: Array; + capabilities: Array; + warning: NotificationContent; +} + +@Component({ + standalone: true, + selector: 'valtimo-plugin-upload-modal', + templateUrl: './plugin-upload-modal.component.html', + styleUrls: ['./plugin-upload-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ReactiveFormsModule, + ModalModule, + ButtonModule, + DropdownModule, + FileUploaderModule, + LayerModule, + LoadingModule, + NotificationModule, + ValtimoCdsModalDirective, + ConfirmationModalModule, + PluginExternalPermissionsComponent, + ], +}) +export class PluginUploadModalComponent implements OnChanges { + @Input() public open = false; + @Input() public hosts: Array = []; + + @Output() public closeEvent = new EventEmitter(); + @Output() public uploadedEvent = new EventEmitter(); + + public readonly $uploading = signal(false); + public readonly $hostItems = signal>([]); + public readonly $selectedHostId = signal(null); + + // Drives the "upload anyway?" confirmation shown when the backend rejects an incompatible plugin. + public readonly _compatibilityModalOpen$ = new BehaviorSubject(false); + public readonly $compatibilityWarning = signal(''); + + // Inline notification for outcomes the modal handles itself: an identical package that is + // already on the host (info) or any other host rejection (error). These 409s are deliberately + // kept off the global error toast (X-Skip-Interceptor), so this notification is their only + // surface. + public readonly $uploadNotification = signal(null); + + // Drives the overwrite-review dialog shown when the uploaded pluginId@version already exists + // with different content: the admin re-reviews the requested permissions and explicitly + // confirms before the version is overwritten. + public readonly $overwriteReview = signal(null); + public readonly $overwriteAcknowledged = signal(false); + + public readonly _fileForm = this._formBuilder.group({ + file: this._formBuilder.control(new Set(), [Validators.required]), + }); + + public readonly $fileSelected = signal(false); + + private readonly _destroyRef = inject(DestroyRef); + + constructor( + private readonly _formBuilder: FormBuilder, + private readonly _externalPluginService: ExternalPluginService, + private readonly _translateService: TranslateService + ) { + this._fileForm + .get('file')! + .valueChanges.pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(value => { + this.$fileSelected.set(value instanceof Set && value.size > 0); + }); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['hosts']) { + const selectedHostId = this.$selectedHostId(); + this.$hostItems.set( + (this.hosts ?? []).map(host => ({ + content: `${host.name} (${host.baseUrl})`, + selected: host.id === selectedHostId, + hostId: host.id, + })) + ); + } + } + + public onHostSelected(event: {item: ListItem & {hostId?: string}}): void { + this.$selectedHostId.set(event?.item?.hostId ?? null); + } + + public onUpload(force = false, overwrite = false): void { + const hostId = this.$selectedHostId(); + const fileSet = this._fileForm.value.file; + const file: File | undefined = fileSet?.values()?.next()?.value?.file; + + if (!hostId || !file) return; + + this.$uploading.set(true); + this.$uploadNotification.set(null); + + this._externalPluginService.uploadPlugin(hostId, file, force, overwrite).subscribe({ + next: () => { + this.$uploading.set(false); + this.uploadedEvent.emit(); + this._resetAndClose(); + }, + error: (error: HttpErrorResponse) => { + this.$uploading.set(false); + if (error.status === 409 && error.error?.incompatible) { + this.$compatibilityWarning.set( + buildExternalPluginCompatibilityMessage(error.error, this._translateService) + ); + this._compatibilityModalOpen$.next(true); + } else if (error.status === 409 && error.error?.code === 'PLUGIN_VERSION_EXISTS') { + this._handleVersionExists(error.error); + } else if (error.status === 409) { + this.$uploadNotification.set(this._buildUploadErrorNotification(error)); + } + }, + }); + } + + public onConfirmOverwrite(): void { + this.$overwriteReview.set(null); + // Compatibility was already checked (or explicitly forced) on the attempt that produced the + // version-exists 409; force=true keeps the compatibility gate from prompting a second time. + this.onUpload(true, true); + } + + public onCancelOverwrite(): void { + this.$overwriteReview.set(null); + } + + public onOverwriteValidityChange(valid: boolean): void { + this.$overwriteAcknowledged.set(valid); + } + + public onConfirmIncompatibleUpload(): void { + this._compatibilityModalOpen$.next(false); + this.onUpload(true); + } + + public onCancelIncompatibleUpload(): void { + this._compatibilityModalOpen$.next(false); + } + + public onClose(): void { + if (this.$uploading()) return; + this._resetAndClose(); + } + + private _resetAndClose(): void { + this.closeEvent.emit(); + this.$selectedHostId.set(null); + this.$fileSelected.set(false); + this.$uploadNotification.set(null); + this.$overwriteReview.set(null); + this.$overwriteAcknowledged.set(false); + this._fileForm.reset({file: new Set()}); + } + + /** + * The uploaded pluginId@version already exists on the host. Identical content means there is + * nothing to overwrite — a friendly info suffices. Different (or undeterminable) content opens + * the overwrite-review dialog: the requested permissions from the enriched 409 body are shown + * for re-review and the admin must explicitly acknowledge them before the overwrite proceeds. + */ + private _handleVersionExists(body: { + pluginId?: string; + version?: string; + currentContentHash?: string; + uploadedContentHash?: string; + requestedEndpoints?: Array; + requestedEventSubscriptions?: Array; + requestedCapabilities?: Array; + }): void { + const identical = + !!body.currentContentHash && + !!body.uploadedContentHash && + body.currentContentHash === body.uploadedContentHash; + + if (identical) { + this.$uploadNotification.set({ + type: 'info', + title: this._translateService.instant('pluginManagement.upload.identicalTitle'), + message: this._translateService.instant('pluginManagement.upload.identicalMessage'), + showClose: false, + lowContrast: true, + }); + return; + } + + this.$overwriteAcknowledged.set(false); + this.$overwriteReview.set({ + pluginId: body.pluginId ?? '', + version: body.version ?? '', + endpoints: body.requestedEndpoints ?? [], + eventSubscriptions: body.requestedEventSubscriptions ?? [], + capabilities: body.requestedCapabilities ?? [], + warning: { + type: 'warning', + title: this._translateService.instant('pluginManagement.upload.overwriteWarningTitle'), + message: this._translateService.instant('pluginManagement.upload.overwriteWarning', { + pluginId: body.pluginId ?? '', + version: body.version ?? '', + }), + showClose: false, + lowContrast: true, + }, + }); + } + + private _buildUploadErrorNotification(error: HttpErrorResponse): NotificationContent { + const hostBody = this._parseRelayedHostBody(error); + const message = [ + this._translateService.instant('pluginManagement.upload.rejected'), + hostBody?.message ?? hostBody?.error ?? '', + ] + .filter(Boolean) + .join(' '); + + return { + type: 'error', + title: this._translateService.instant('pluginManagement.upload.failedTitle'), + message, + showClose: false, + lowContrast: true, + }; + } + + // The backend relays a host rejection as `{error, detail}` where `detail` holds the host's raw + // JSON body (e.g. `{code, error, message}`); non-JSON detail is shown as-is. + private _parseRelayedHostBody( + error: HttpErrorResponse + ): {code?: string; error?: string; message?: string} | null { + const detail = error.error?.detail; + if (typeof detail !== 'string' || detail.length === 0) return null; + + try { + return JSON.parse(detail); + } catch { + return {message: detail}; + } + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html new file mode 100644 index 0000000000..b45bc9a66f --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html @@ -0,0 +1,141 @@ + + + + +

+ {{ titleTranslationKey | translate }} +

+
+ +
+

+ {{ descriptionTranslationKey | translate: {entityName: entityName} }} +

+ + + + + {{ 'pluginManagement.usageModal.headers.activity' | translate }} + + + {{ 'pluginManagement.usageModal.headers.configuration' | translate }} + + + {{ 'pluginManagement.usageModal.headers.type' | translate }} + + + {{ 'pluginManagement.usageModal.headers.linkedTo' | translate }} + + + {{ 'pluginManagement.usageModal.headers.processDefinition' | translate }} + + + + + +
+ {{ + usage.activityName || + usage.activityId || + usage.tabName || + usage.tabKey || + usage.buildingBlockKey + }} +
+
+ {{ usage.activityId }} +
+
+ {{ 'pluginManagement.usageModal.caseWidget' | translate }} + {{ usage.widgetKey }} +
+
+ {{ 'pluginManagement.usageModal.caseTab' | translate }} +
+
+ {{ 'pluginManagement.usageModal.buildingBlockMapping' | translate }} +
+
+ + + {{ usage.configurationTitle }} + + + + + {{ 'pluginManagement.usageModal.parentType.' + usage.parentType | translate }} + + + + + + {{ usage.parentKey }} + + {{ usage.parentVersionTag }} + + + + + + +
+ {{ usage.processDefinitionName || usage.processDefinitionKey }} +
+
+ {{ usage.processDefinitionKey }} +
+
+ + + + {{ usage.processDefinitionId }} + + +
+ + + + +
+
+
+ + + + +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.scss b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.scss new file mode 100644 index 0000000000..f0760d9f1b --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.scss @@ -0,0 +1,32 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.plugin-usage-modal { + &__intro { + margin-bottom: var(--cds-spacing-05); + } + + &__cell-main { + word-break: break-word; + } + + &__cell-sub { + display: block; + color: var(--cds-text-secondary); + font-size: 0.75rem; + margin-top: var(--cds-spacing-01); + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts new file mode 100644 index 0000000000..00cc51453b --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TranslateModule} from '@ngx-translate/core'; +import { + ButtonModule, + ModalModule, + StructuredListModule, + TagModule, +} from 'carbon-components-angular'; +import {ValtimoCdsModalDirective} from '@valtimo/components'; +import {ExternalPluginHostUsage, ExternalPluginHostUsageParentType} from '@valtimo/plugin'; + +/** + * Read-only modal shown when an admin tries to delete an external plugin entity (a host or a + * configuration) that is still referenced by one or more BPMN process links, case tabs, case + * widgets or building-block mappings. The list mirrors the `usages` payload the backend would + * attach to a 409 from the corresponding `DELETE` endpoint; only "Close" is offered — there is no + * force-delete. + * + * The parent supplies the heading + description translation keys so the same modal can be + * reused for hosts and configurations (and any future entity with the same usage shape). + */ +@Component({ + standalone: true, + selector: 'valtimo-plugin-usage-modal', + templateUrl: './plugin-usage-modal.component.html', + styleUrls: ['./plugin-usage-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + TranslateModule, + ModalModule, + ButtonModule, + StructuredListModule, + TagModule, + ValtimoCdsModalDirective, + ], +}) +export class PluginUsageModalComponent { + @Input() public open = false; + @Input() public titleTranslationKey = ''; + @Input() public descriptionTranslationKey = ''; + @Input() public entityName: string | null = null; + @Input() public usages: Array = []; + + @Output() public closeEvent = new EventEmitter(); + + public onClose(): void { + this.closeEvent.emit(); + } + + public trackByUsage(_index: number, usage: ExternalPluginHostUsage): string { + if (usage.processLinkId) { + return usage.processLinkId; + } + if (usage.widgetKey) { + return `widget:${usage.parentKey}:${usage.tabKey}:${usage.widgetKey}`; + } + if (usage.tabKey) { + return `tab:${usage.parentKey}:${usage.tabKey}`; + } + // Building-block mapping usage on a case-definition ↔ BB link (no process link, no tab). + return `bb:${usage.parentKey}:${usage.buildingBlockKey}:${usage.configurationId}`; + } + + public parentTypeTagColor(parentType: ExternalPluginHostUsageParentType): string { + switch (parentType) { + case 'CASE': + return 'blue'; + case 'BUILDING_BLOCK': + return 'purple'; + case 'GLOBAL': + default: + return 'cool-gray'; + } + } +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/models/external-plugin-permissions.model.ts b/frontend/projects/valtimo/plugin-management/src/lib/models/external-plugin-permissions.model.ts new file mode 100644 index 0000000000..9591f0dc61 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/models/external-plugin-permissions.model.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ExternalPluginEndpoint} from '@valtimo/plugin'; + +/** A manifest endpoint enriched with its localized description for display in the permission list. */ +interface EnrichedEndpoint extends ExternalPluginEndpoint { + description: string | null; +} + +export {EnrichedEndpoint}; diff --git a/frontend/projects/valtimo/plugin-management/src/lib/models/index.ts b/frontend/projects/valtimo/plugin-management/src/lib/models/index.ts index 7dccefcaf0..e5cbe7c617 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/models/index.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/models/index.ts @@ -14,4 +14,6 @@ * limitations under the License. */ +export * from './external-plugin-permissions.model'; export * from './plugin-modal.model'; +export * from './unified-plugin-definition.model'; diff --git a/frontend/projects/valtimo/plugin-management/src/lib/models/unified-plugin-definition.model.ts b/frontend/projects/valtimo/plugin-management/src/lib/models/unified-plugin-definition.model.ts new file mode 100644 index 0000000000..e1c9415fc2 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/models/unified-plugin-definition.model.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {PluginDefinitionWithLogo} from '@valtimo/plugin'; + +type PluginConfigurationSource = 'embedded' | 'external'; + +interface UnifiedPluginDefinition extends PluginDefinitionWithLogo { + source: PluginConfigurationSource; + externalDefinitionId?: string; + externalName?: string | null; + externalDescription?: string | null; + externalLogoUrl?: string | null; +} + +interface UnifiedPluginConfigurationRow { + id?: string; + title: string; + pluginName: string; + definitionKey: string; + source: PluginConfigurationSource; + sourceLabel?: string; + properties?: object; + pluginDefinition?: {key: string}; + externalDefinitionId?: string; + /** + * Set on external rows whose plugin definition is incompatible with the running GZAC version. + * Drives the "Incompatible" tag and its tooltip ([compatibilityMessage]) in the table. + */ + incompatible?: boolean; + compatibilityMessage?: string; + hostName?: string; +} + +export {PluginConfigurationSource, UnifiedPluginDefinition, UnifiedPluginConfigurationRow}; diff --git a/frontend/projects/valtimo/plugin-management/src/lib/plugin-management-routing.ts b/frontend/projects/valtimo/plugin-management/src/lib/plugin-management-routing.ts index 0222178289..5319a481ab 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/plugin-management-routing.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/plugin-management-routing.ts @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import {CommonModule} from '@angular/common'; import {AuthGuardService} from '@valtimo/security'; import {ROLE_ADMIN} from '@valtimo/shared'; import {PluginManagementComponent} from './components/plugin-management/plugin-management.component'; +import {PluginHostsPageComponent} from './components/plugin-hosts-page/plugin-hosts-page.component'; +import {PluginAppsPageComponent} from './components/plugin-apps-page/plugin-apps-page.component'; const routes: Routes = [ { @@ -28,6 +30,18 @@ const routes: Routes = [ canActivate: [AuthGuardService], data: {title: 'Plugins', roles: [ROLE_ADMIN]}, }, + { + path: 'plugin-hosts', + component: PluginHostsPageComponent, + canActivate: [AuthGuardService], + data: {title: 'Plugin hosts', roles: [ROLE_ADMIN]}, + }, + { + path: 'plugin-apps', + component: PluginAppsPageComponent, + canActivate: [AuthGuardService], + data: {title: 'Apps', roles: [ROLE_ADMIN]}, + }, ]; @NgModule({ diff --git a/frontend/projects/valtimo/plugin-management/src/lib/plugin-management.module.ts b/frontend/projects/valtimo/plugin-management/src/lib/plugin-management.module.ts index d86210fecf..08467449a2 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/plugin-management.module.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/plugin-management.module.ts @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ import {NgModule} from '@angular/core'; import {PluginManagementRoutingModule} from './plugin-management-routing'; import {CommonModule} from '@angular/common'; +import {ReactiveFormsModule} from '@angular/forms'; import {TranslateModule} from '@ngx-translate/core'; import {PluginManagementComponent} from './components/plugin-management/plugin-management.component'; import {PluginAddModalComponent} from './components/plugin-add-modal/plugin-add-modal.component'; @@ -26,11 +27,18 @@ import {PluginConfigureComponent} from './components/plugin-configure/plugin-con import {PluginConfigurationContainerModule, PluginTranslatePipeModule} from '@valtimo/plugin'; import {PluginEditModalComponent} from './components/plugin-edit-modal/plugin-edit-modal.component'; import {PluginEditComponent} from './components/plugin-edit/plugin-edit.component'; +import {PluginExternalEditModalComponent} from './components/plugin-external-edit-modal/plugin-external-edit-modal.component'; +import {PluginExternalConfigureComponent} from './components/plugin-external-configure/plugin-external-configure.component'; +import {PluginExternalPermissionsComponent} from './components/plugin-external-permissions/plugin-external-permissions.component'; +import {PluginUploadModalComponent} from './components/plugin-upload-modal/plugin-upload-modal.component'; +import {PluginUsageModalComponent} from './components/plugin-usage-modal/plugin-usage-modal.component'; +import {PluginLogModalComponent} from './components/plugin-log-modal/plugin-log-modal.component'; import { CarbonListModule, + ConfirmationModalModule, ParagraphModule, - StepperModule, TableModule, + TooltipModule as VTooltipModule, ValtimoCdsModalDirective, VModalModule, } from '@valtimo/components'; @@ -40,7 +48,11 @@ import { LayerModule, LoadingModule, ModalModule as CarbonModalModule, + NotificationModule, + ProgressIndicatorModule, + TagModule, TilesModule, + TooltipModule, } from 'carbon-components-angular'; @NgModule({ @@ -55,11 +67,12 @@ import { ], imports: [ CommonModule, + ReactiveFormsModule, PluginManagementRoutingModule, TranslateModule, ParagraphModule, TableModule, - StepperModule, + ProgressIndicatorModule, VModalModule, PluginTranslatePipeModule, PluginConfigurationContainerModule, @@ -67,10 +80,21 @@ import { CarbonListModule, CarbonModalModule, IconModule, + NotificationModule, + TagModule, + TooltipModule, ValtimoCdsModalDirective, LayerModule, TilesModule, LoadingModule, + PluginExternalEditModalComponent, + PluginExternalConfigureComponent, + PluginExternalPermissionsComponent, + PluginUploadModalComponent, + PluginUsageModalComponent, + PluginLogModalComponent, + ConfirmationModalModule, + VTooltipModule, ], exports: [ PluginManagementComponent, diff --git a/frontend/projects/valtimo/plugin-management/src/lib/utils/external-plugin-compatibility.util.ts b/frontend/projects/valtimo/plugin-management/src/lib/utils/external-plugin-compatibility.util.ts new file mode 100644 index 0000000000..e3d8e000eb --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/utils/external-plugin-compatibility.util.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {TranslateService} from '@ngx-translate/core'; +import {ExternalPluginCompatibilityInfo} from '@valtimo/plugin'; + +/** + * Builds the localized, non-blocking compatibility warning shown wherever an incompatible external + * plugin surfaces (add-configuration modal, configuration table tooltip, upload confirmation). + * + * Keeps it simple: state that the version is incompatible, the version in use, and the supported + * bounds — the maximum only when the plugin declares one. Single source of truth so every entry + * point reads identically. + */ +export function buildExternalPluginCompatibilityMessage( + info: ExternalPluginCompatibilityInfo, + translateService: TranslateService +): string { + const current = info.currentGzacVersion ?? '?'; + + const parts: string[] = [ + translateService.instant('pluginManagement.compatibility.intro'), + translateService.instant('pluginManagement.compatibility.current', {current}), + ]; + + if (info.minGzacVersion) { + parts.push( + translateService.instant('pluginManagement.compatibility.minimum', {min: info.minGzacVersion}) + ); + } + if (info.maxGzacVersion) { + parts.push( + translateService.instant('pluginManagement.compatibility.maximum', {max: info.maxGzacVersion}) + ); + } + + return parts.join(' '); +} diff --git a/frontend/projects/valtimo/plugin-management/src/lib/utils/index.ts b/frontend/projects/valtimo/plugin-management/src/lib/utils/index.ts new file mode 100644 index 0000000000..a63fe9ff90 --- /dev/null +++ b/frontend/projects/valtimo/plugin-management/src/lib/utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './external-plugin-compatibility.util'; diff --git a/frontend/projects/valtimo/plugin-management/src/public-api.ts b/frontend/projects/valtimo/plugin-management/src/public-api.ts index e3b68e889b..a99a6923db 100644 --- a/frontend/projects/valtimo/plugin-management/src/public-api.ts +++ b/frontend/projects/valtimo/plugin-management/src/public-api.ts @@ -27,3 +27,6 @@ export * from './lib/components/plugin-add-select/plugin-add-select.component'; export * from './lib/components/plugin-configure/plugin-configure.component'; export * from './lib/components/plugin-edit-modal/plugin-edit-modal.component'; export * from './lib/components/plugin-edit/plugin-edit.component'; +export * from './lib/components/plugin-host-modal/plugin-host-modal.component'; +export * from './lib/components/plugin-hosts-page/plugin-hosts-page.component'; +export * from './lib/components/plugin-apps-page/plugin-apps-page.component'; diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.html b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.html new file mode 100644 index 0000000000..0596dbabaa --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.html @@ -0,0 +1,31 @@ + + + + diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.scss b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.scss new file mode 100644 index 0000000000..308477680d --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.scss @@ -0,0 +1,27 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: block; + height: 100%; +} + +.external-plugin-iframe { + display: block; + width: 100%; + height: 100%; + border: none; +} diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.spec.ts b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.spec.ts new file mode 100644 index 0000000000..aebe7421c3 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.spec.ts @@ -0,0 +1,272 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {ConfigService} from '@valtimo/shared'; +import {ExternalPluginIframeComponent} from './external-plugin-iframe.component'; + +describe('ExternalPluginIframeComponent', () => { + let fixture: ComponentFixture; + let component: ExternalPluginIframeComponent; + + const configServiceMock = { + config: {valtimoApi: {endpointUri: '/api/'}}, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ExternalPluginIframeComponent, TranslateModule.forRoot()], + providers: [{provide: ConfigService, useValue: configServiceMock}], + }); + + fixture = TestBed.createComponent(ExternalPluginIframeComponent); + component = fixture.componentInstance; + component.userToken = 'test-user-token'; + }); + + const proxyToGzac = (method: string, path: string): Promise<{status: number; body: unknown}> => + (component as any)._proxyToGzac(method, path, undefined); + + describe('same-origin enforcement', () => { + it('rejects an absolute cross-origin URL with a 403 response', async () => { + const fetchSpy = spyOn(window, 'fetch'); + + const result = await proxyToGzac('GET', 'https://evil.example/api/v1/steal'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a protocol-relative URL with a 403 response', async () => { + const fetchSpy = spyOn(window, 'fetch'); + + const result = await proxyToGzac('GET', '//evil.example/api/v1/steal'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a javascript: URL with a 403 response', async () => { + const fetchSpy = spyOn(window, 'fetch'); + + const result = await proxyToGzac('GET', 'javascript:alert(1)'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a malformed URL with a 403 response', async () => { + const fetchSpy = spyOn(window, 'fetch'); + + const result = await proxyToGzac('GET', 'http://'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('API base path enforcement', () => { + it('rejects a same-origin path outside the API base path', async () => { + const fetchSpy = spyOn(window, 'fetch'); + + const result = await proxyToGzac('GET', '/other/endpoint'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('accepts a valid API path and fetches the normalized same-origin path with the user token', async () => { + const fetchSpy = spyOn(window, 'fetch').and.resolveTo( + new Response(JSON.stringify({ok: true}), {status: 200}) + ); + + const result = await proxyToGzac('GET', `${window.location.origin}/api/v1/documents?page=1`); + + expect(result.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [requestPath, init] = fetchSpy.calls.mostRecent().args; + expect(requestPath).toBe('/api/v1/documents?page=1'); + expect((init?.headers as Record)['Authorization']).toBe( + 'Bearer test-user-token' + ); + }); + }); + + describe('allowlist precheck', () => { + let fetchSpy: jasmine.Spy; + + beforeEach(() => { + fetchSpy = spyOn(window, 'fetch').and.resolveTo(new Response('{}', {status: 200})); + }); + + it('skips the precheck when no allowlist input is provided', async () => { + component.allowedEndpoints = undefined; + + const result = await proxyToGzac('GET', '/api/v1/documents'); + + expect(result.status).toBe(200); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('denies every call when an empty allowlist is provided', async () => { + component.allowedEndpoints = []; + + const result = await proxyToGzac('GET', '/api/v1/documents'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('allows a call matching a granted endpoint pattern', async () => { + component.allowedEndpoints = [{method: 'GET', pattern: '/api/v1/documents/**'}]; + + const result = await proxyToGzac('GET', '/api/v1/documents/abc/sub'); + + expect(result.status).toBe(200); + }); + + it('denies a call whose method does not match the granted endpoint', async () => { + component.allowedEndpoints = [{method: 'GET', pattern: '/api/v1/documents/**'}]; + + const result = await proxyToGzac('POST', '/api/v1/documents/abc'); + + expect(result.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('treats a single wildcard as one path segment', async () => { + component.allowedEndpoints = [{method: 'GET', pattern: '/api/v1/documents/*'}]; + + expect((await proxyToGzac('GET', '/api/v1/documents/abc')).status).toBe(200); + expect((await proxyToGzac('GET', '/api/v1/documents/abc/def')).status).toBe(403); + }); + }); + + describe('plugin data proxy', () => { + const proxyToPlugin = (method: string, path: string): Promise<{status: number; body: unknown}> => + (component as any)._proxyToPlugin(method, path, undefined, undefined); + + beforeEach(() => { + component.pluginDataUrl = 'https://host.example/plugins/p/1.0.0/data'; + component.configurationId = 'cfg-1'; + }); + + it('answers locally with a 401 when no user token is available yet', async () => { + const fetchSpy = spyOn(window, 'fetch'); + component.userToken = null; + + const result = await proxyToPlugin('GET', '/summary'); + + expect(result.status).toBe(401); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('POSTs to the host with the configurationId and the user token', async () => { + const fetchSpy = spyOn(window, 'fetch').and.resolveTo( + new Response(JSON.stringify({ok: true}), {status: 200}) + ); + + const result = await proxyToPlugin('GET', '/summary'); + + expect(result.status).toBe(200); + const [url, init] = fetchSpy.calls.mostRecent().args; + expect(url).toBe('https://host.example/plugins/p/1.0.0/data'); + const payload = JSON.parse(init?.body as string); + expect(payload.configurationId).toBe('cfg-1'); + expect(payload.userToken).toBe('test-user-token'); + }); + }); + + describe('message filtering', () => { + let iframe: HTMLIFrameElement; + + beforeEach(() => { + iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + (component as any).iframeRef = {nativeElement: iframe}; + }); + + afterEach(() => { + iframe.remove(); + }); + + const emitMessage = (data: unknown, source: MessageEventSource | null): void => + (component as any)._onMessage({data, source} as MessageEvent); + + it('handles a message coming from the iframe contentWindow', () => { + const readySpy = spyOn(component.readyEvent, 'emit'); + + emitMessage({source: 'valtimo-plugin', event: 'ready'}, iframe.contentWindow); + + expect(readySpy).toHaveBeenCalled(); + }); + + it('ignores messages whose source is not the iframe contentWindow', () => { + const readySpy = spyOn(component.readyEvent, 'emit'); + + emitMessage({source: 'valtimo-plugin', event: 'ready'}, window); + + expect(readySpy).not.toHaveBeenCalled(); + }); + + it('ignores messages without the valtimo-plugin source marker', () => { + const readySpy = spyOn(component.readyEvent, 'emit'); + + emitMessage({source: 'something-else', event: 'ready'}, iframe.contentWindow); + + expect(readySpy).not.toHaveBeenCalled(); + }); + + it('posts an init message without any token fields', () => { + const postMessageSpy = spyOn(iframe.contentWindow as Window, 'postMessage'); + + component.onIframeLoad(); + + expect(postMessageSpy).toHaveBeenCalledTimes(1); + const message = postMessageSpy.calls.mostRecent().args[0] as { + event: string; + payload: Record; + }; + expect(message.event).toBe('init'); + expect('accessToken' in message.payload).toBeFalse(); + expect('token' in message.payload).toBeFalse(); + expect('userToken' in message.payload).toBeFalse(); + }); + }); + + describe('bundle URL validation', () => { + afterEach(() => { + component.ngOnDestroy(); + }); + + it('trusts an https bundle URL', () => { + component.bundleUrl = 'https://plugins.example.com/bundles/tab.html'; + + component.ngOnInit(); + + expect(component.$trustedUrl()).not.toBeNull(); + }); + + it('does not trust a javascript: bundle URL', () => { + component.bundleUrl = 'javascript:alert(1)'; + + component.ngOnInit(); + + expect(component.$trustedUrl()).toBeNull(); + }); + }); +}); diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.ts b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.ts new file mode 100644 index 0000000000..e490f529ee --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-iframe/external-plugin-iframe.component.ts @@ -0,0 +1,379 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + signal, + ViewChild, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; +import {TranslateService} from '@ngx-translate/core'; +import {ConfigService} from '@valtimo/shared'; +import {ExternalPluginEndpoint} from '../../models'; + +@Component({ + standalone: true, + selector: 'valtimo-external-plugin-iframe', + templateUrl: './external-plugin-iframe.component.html', + styleUrls: ['./external-plugin-iframe.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], +}) +export class ExternalPluginIframeComponent implements OnInit, OnDestroy { + @ViewChild('pluginIframe') public iframeRef!: ElementRef; + + @Input() public bundleUrl!: string; + @Input() public context: Record = {}; + @Input() public prefillConfiguration: { + title: string; + configuration: Record; + } | null = null; + /** + * Downscoped user token used **parent-side only** to authorise proxied GZAC reads. It is never + * sent into the iframe (the iframe is at an opaque origin and holds no credential). + */ + @Input() public userToken: string | null = null; + /** `${baseUrl}/${version}/data` — the plugin host route that backs `target: "plugin"` requests. */ + @Input() public pluginDataUrl: string | null = null; + /** Optional client-side allowlist precheck for GZAC proxy calls (the server is authoritative). */ + @Input() public allowedEndpoints?: ExternalPluginEndpoint[]; + /** External-plugin configuration id, forwarded to the plugin host for `target: "plugin"` calls. */ + @Input() public configurationId: string | null = null; + + @Output() public configurationChangedEvent = new EventEmitter<{ + valid: boolean; + title: string; + data: Record; + }>(); + @Output() public readyEvent = new EventEmitter(); + /** + * Emitted when a task-form bundle reports it has itself completed the user task (Level 2 — through + * the plugin, under the downscoped user token). The host reacts by closing the task and refreshing + * the list — it does not complete the task itself. + */ + @Output() public taskCompletedEvent = new EventEmitter(); + /** + * Emitted when a task-form bundle hands its collected data up to be submitted (Level 0/1). The + * consumer submits it to GZAC and must reply with {@link sendSubmitResult} using the same + * `correlationId` so the iframe's `submitTask` promise resolves. + */ + @Output() public submitTaskEvent = new EventEmitter<{ + correlationId: string; + data: Record; + }>(); + + public readonly $trustedUrl = signal(null); + + private readonly _onMessageBound = this._onMessage.bind(this); + /** Pathname prefix all proxied GZAC calls must stay under (derived from the API endpoint URI). */ + private readonly _apiBasePath: string; + + constructor( + private readonly _sanitizer: DomSanitizer, + private readonly _translateService: TranslateService, + private readonly _configService: ConfigService + ) { + this._apiBasePath = this._deriveApiBasePath(); + } + + public ngOnInit(): void { + // Only http(s) bundle URLs may be trusted as an iframe src: anything else (javascript:, data:, + // malformed, …) would defeat the sandbox and is silently ignored, leaving the iframe unrendered. + if (this.bundleUrl && this._isSafeBundleUrl(this.bundleUrl)) { + this.$trustedUrl.set(this._sanitizer.bypassSecurityTrustResourceUrl(this.bundleUrl)); + } + + window.addEventListener('message', this._onMessageBound); + } + + public ngOnDestroy(): void { + window.removeEventListener('message', this._onMessageBound); + } + + public triggerSave(): void { + this._postToIframe('save', {}); + } + + /** + * Reply to a {@link submitTaskEvent}: tell the iframe whether GZAC accepted the submission. On a + * validation failure `errors`/`fieldErrors` let the plugin render messages without being torn down. + */ + public sendSubmitResult(result: { + correlationId: string; + ok: boolean; + errors?: string[]; + fieldErrors?: Record; + }): void { + this._postToIframe('submitResult', result); + } + + public sendPrefillConfiguration(prefill: { + title: string; + configuration: Record; + }): void { + this._postToIframe('prefillConfiguration', { + title: prefill.title, + configuration: prefill.configuration, + }); + } + + public onIframeLoad(): void { + this._postToIframe('init', { + context: this.context, + theme: 'white', + locale: this._translateService.currentLang ?? this._translateService.defaultLang ?? 'en', + }); + } + + private _postToIframe(event: string, payload: unknown): void { + const iframe = this.iframeRef?.nativeElement; + if (!iframe?.contentWindow) return; + + // The iframe is at an opaque origin (sandbox without allow-same-origin), which cannot be + // addressed by a specific targetOrigin — so we post to '*'. Acceptable because nothing secret + // (never the token) is ever sent into the iframe. + iframe.contentWindow.postMessage({source: 'valtimo-host', event, payload}, '*'); + } + + private _onMessage(event: MessageEvent): void { + const data = event.data; + if (!data || typeof data !== 'object' || data.source !== 'valtimo-plugin') return; + + // An opaque-origin iframe reports `event.origin === "null"`, so origin-equality can't be used. + // Validate the message comes from *our* iframe by comparing the source window instead. + const iframe = this.iframeRef?.nativeElement; + if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return; + + switch (data.event) { + case 'ready': + this.readyEvent.emit(); + if (this.prefillConfiguration) { + this.sendPrefillConfiguration(this.prefillConfiguration); + } + break; + case 'configurationChanged': + this.configurationChangedEvent.emit(data.payload); + break; + case 'taskCompleted': + this.taskCompletedEvent.emit(); + break; + case 'submitTask': + this.submitTaskEvent.emit(data.payload); + break; + case 'proxyRequest': + void this._handleProxyRequest(data.payload); + break; + } + } + + /** + * Performs an allow-listed call on the iframe's behalf and posts the **data only** back as a + * `proxyResponse`. The downscoped user token is attached parent-side and never enters a + * postMessage. + */ + private async _handleProxyRequest(payload: { + correlationId: string; + target: 'gzac' | 'plugin'; + method: string; + path: string; + query?: Record; + body?: unknown; + headers?: Record; + }): Promise { + const {correlationId, target, method, path, query, body, headers} = payload ?? ({} as never); + + try { + const result = + target === 'gzac' + ? await this._proxyToGzac(method, path, body, headers) + : await this._proxyToPlugin(method, path, query, body); + this._postToIframe('proxyResponse', {correlationId, ...result}); + } catch (error) { + this._postToIframe('proxyResponse', { + correlationId, + status: 0, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private async _proxyToGzac( + method: string, + path: string, + body: unknown, + headers?: Record + ): Promise<{status: number; body: unknown}> { + if (!this.userToken) { + throw new Error('No user token available for proxied GZAC call'); + } + + // The iframe-supplied path is untrusted: resolve it against our own origin and hard-require the + // result to stay same-origin. This rejects absolute URLs to other origins, protocol-relative + // `//host/...` forms and non-http schemes like `javascript:` — a compromised bundle must never + // be able to point the fetch (and thus the bearer token) at a foreign host. + let url: URL; + try { + url = new URL(path, window.location.origin); + } catch { + return {status: 403, body: {error: 'Malformed proxy request path'}}; + } + if (url.origin !== window.location.origin) { + return {status: 403, body: {error: 'Proxied GZAC calls must be same-origin'}}; + } + + // Second hard guarantee: only paths under the GZAC API base path may be reached via the proxy. + if (!url.pathname.startsWith(this._apiBasePath)) { + return { + status: 403, + body: {error: `Proxied GZAC calls must target the API base path (${this._apiBasePath})`}, + }; + } + + // From here on only the normalized same-origin path is used — never the raw iframe input. + const normalizedPath = url.pathname + url.search; + + if (!this._isAllowed(method, normalizedPath)) { + return {status: 403, body: {error: 'Endpoint not allowed for this plugin surface'}}; + } + + const upperMethod = method.toUpperCase(); + const hasBody = body !== undefined && upperMethod !== 'GET' && upperMethod !== 'HEAD'; + + // Raw fetch (NOT HttpClient) so the Keycloak bearer interceptor never attaches the full Keycloak + // token alongside the downscoped one — a confused-deputy guard. + const response = await fetch(normalizedPath, { + method: upperMethod, + headers: { + ...(headers ?? {}), + Authorization: `Bearer ${this.userToken}`, + ...(hasBody ? {'Content-Type': 'application/json'} : {}), + }, + ...(hasBody ? {body: JSON.stringify(body)} : {}), + }); + + return {status: response.status, body: await this._readBody(response)}; + } + + private async _proxyToPlugin( + method: string, + path: string, + query: Record | undefined, + body: unknown + ): Promise<{status: number; body: unknown}> { + if (!this.pluginDataUrl) { + throw new Error('No plugin data URL configured'); + } + + // The host requires the downscoped user token and introspects it against GZAC before any Wasm + // runs, so a call without one can never succeed. Answer locally instead of burning a network + // round-trip; the plugin can retry once the tab has minted its token. + if (!this.userToken) { + return {status: 401, body: {error: 'User token not available for plugin data call'}}; + } + + const response = await fetch(this.pluginDataUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + configurationId: this.configurationId ?? undefined, + method, + path, + query, + body, + context: this.context, + // Forward the downscoped user token: the host requires it (it introspects the token against + // GZAC before executing Wasm), and it lets a `handle_request` handler call GZAC *as the + // user* (gzacApi.asUser). NOTE: this hands the user token to the plugin host — a deliberate + // relaxation of the "token never leaves the browser" guarantee, bounded by PBAC ∩ allowlist + // and the token's short TTL. The plugin only receives data, never the token itself. + userToken: this.userToken, + }), + }); + + return {status: response.status, body: await this._readBody(response)}; + } + + private async _readBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } + } + + /** + * Client-side allowlist precheck. Semantics of `allowedEndpoints`: + * - `undefined` — no allowlist was provided: skip this precheck. The same-origin + API-base-path + * hard guarantees in {@link _proxyToGzac} and the server-side allowlist remain authoritative. + * - empty array — an allowlist WAS provided but grants nothing: deny every call. An empty + * allowlist must never be treated as "allow all". + */ + private _isAllowed(method: string, path: string): boolean { + if (this.allowedEndpoints === undefined) return true; + const pathname = path.split('?')[0]; + return this.allowedEndpoints.some( + endpoint => + endpoint.method.toUpperCase() === method.toUpperCase() && + this._matchesPattern(endpoint.pattern, pathname) + ); + } + + /** + * Computes the pathname of the configured GZAC API endpoint (`valtimoApi.endpointUri`), which may + * be absolute (`http://localhost:8080/api/`) or relative (`/api/`). Falls back to `/api/` when + * missing or unparseable. + */ + private _deriveApiBasePath(): string { + try { + const endpointUri = this._configService.config?.valtimoApi?.endpointUri; + if (!endpointUri) return '/api/'; + const pathname = new URL(endpointUri, window.location.origin).pathname; + return pathname.endsWith('/') ? pathname : `${pathname}/`; + } catch { + return '/api/'; + } + } + + private _isSafeBundleUrl(bundleUrl: string): boolean { + try { + const url = new URL(bundleUrl, window.location.origin); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + } + + private _matchesPattern(pattern: string, pathname: string): boolean { + // Translate an Ant-style pattern (`*` single segment, `**` any) to a RegExp. Mirrors the + // server-side AntPathRequestMatcher closely enough for a client-side precheck. + const regex = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '§§') + .replace(/\*/g, '[^/]*') + .replace(/§§/g, '.*'); + return new RegExp(`^${regex}$`).test(pathname); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.html b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.html new file mode 100644 index 0000000000..175c912abd --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.html @@ -0,0 +1,46 @@ + + +
+ +
+ +
+ +
+ {{ 'externalPluginPage.loadError' | translate }} +
+ + +
+ +
+ + +
+
+
diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.scss b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.scss new file mode 100644 index 0000000000..86f171c2eb --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.scss @@ -0,0 +1,44 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.external-plugin-page { + width: 100%; + background-color: var(--cds-background); + + ::ng-deep { + valtimo-external-plugin-iframe { + display: block; + height: 100%; + } + + .external-plugin-iframe { + width: 100%; + height: 100%; + } + } + + &__status { + display: flex; + align-items: center; + justify-content: center; + padding: var(--cds-spacing-07); + color: var(--cds-text-secondary); + + &--error { + color: var(--cds-text-error); + } + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.ts b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.ts new file mode 100644 index 0000000000..5defec6ed9 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/components/external-plugin-page/external-plugin-page.component.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, OnDestroy, OnInit, signal} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {TranslateModule} from '@ngx-translate/core'; +import {LoadingModule} from 'carbon-components-angular'; +import {map, Subscription, switchMap, tap, throwError} from 'rxjs'; +import {ExternalPluginIframeComponent} from '../external-plugin-iframe/external-plugin-iframe.component'; +import {FitPageDirective} from '@valtimo/components'; +import {ExternalPluginPageService, ExternalPluginSessionService} from '../../services'; +import {ExternalPluginMenuPage} from '../../models'; +import {derivePluginDataUrl} from '../../utils'; + +type PageState = 'loading' | 'ready' | 'error'; + +/** + * Renders an external-plugin `page` bundle as a routed full page. Mirrors the case-tab spine: + * resolves the page descriptor for the route's `configurationId`/`bundleKey`, starts the + * downscoped user-token session (mint + re-mint with retry, owned by the page-scoped + * {@link ExternalPluginSessionService}), derives the plugin data URL, and hosts the shared iframe. + * The iframe is at an opaque origin and never receives the token (parent-proxy only). + */ +@Component({ + templateUrl: './external-plugin-page.component.html', + styleUrls: ['./external-plugin-page.component.scss'], + standalone: true, + providers: [ExternalPluginSessionService], + imports: [ + CommonModule, + LoadingModule, + TranslateModule, + ExternalPluginIframeComponent, + FitPageDirective, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExternalPluginPageComponent implements OnInit, OnDestroy { + public readonly $state = signal('loading'); + public readonly $page = signal(null); + public readonly $pluginDataUrl = signal(null); + public readonly $context = signal>({}); + public readonly $iframeReady = signal(false); + + private readonly _subscriptions = new Subscription(); + + constructor( + private readonly route: ActivatedRoute, + private readonly pageService: ExternalPluginPageService, + protected readonly sessionService: ExternalPluginSessionService + ) {} + + public ngOnInit(): void { + this._subscriptions.add( + this.route.params + .pipe( + map(params => ({ + configurationId: params['configurationId'] as string, + bundleKey: (params['bundleKey'] as string) ?? null, + })), + // Navigating between two plugin pages reuses this component, so reset to the loading state + // on every param change. This tears down the previous `ready` view (and its iframe) so the + // freshly matched page is hosted in a new iframe instead of the reused one keeping its src. + tap(() => { + this.$state.set('loading'); + this.$iframeReady.set(false); + }), + switchMap(({configurationId, bundleKey}) => + this.pageService.getMenuPages().pipe( + map(pages => this._matchPage(pages, configurationId, bundleKey)), + switchMap(page => + page?.bundleUrl + ? this.sessionService.startSession(page.configurationId).pipe(map(() => page)) + : throwError(() => new Error('plugin-page-unavailable')) + ) + ) + ) + ) + .subscribe({ + next: page => this.onLoaded(page), + error: () => this.$state.set('error'), + }) + ); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onIframeReady(): void { + this.$iframeReady.set(true); + } + + private _matchPage( + pages: Array, + configurationId: string, + bundleKey: string | null + ): ExternalPluginMenuPage | null { + const forConfiguration = pages.filter(page => page.configurationId === configurationId); + if (bundleKey) { + return forConfiguration.find(page => page.bundleKey === bundleKey) ?? null; + } + return forConfiguration[0] ?? null; + } + + private onLoaded(page: ExternalPluginMenuPage): void { + this.$page.set(page); + this.$context.set({configurationId: page.configurationId}); + this.$pluginDataUrl.set(derivePluginDataUrl(page.bundleUrl)); + this.$state.set('ready'); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/external-plugin-page-routing.module.ts b/frontend/projects/valtimo/plugin/src/lib/external-plugin-page-routing.module.ts new file mode 100644 index 0000000000..d3c290af05 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/external-plugin-page-routing.module.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {AuthGuardService} from '@valtimo/security'; +import {ROLE_USER} from '@valtimo/shared'; +import {ExternalPluginPageComponent} from './components/external-plugin-page/external-plugin-page.component'; + +const routes: Routes = [ + { + path: 'plugin-pages/:configurationId/:bundleKey', + component: ExternalPluginPageComponent, + canActivate: [AuthGuardService], + data: {roles: [ROLE_USER]}, + }, + { + path: 'plugin-pages/:configurationId', + component: ExternalPluginPageComponent, + canActivate: [AuthGuardService], + data: {roles: [ROLE_USER]}, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ExternalPluginPageRoutingModule {} diff --git a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin-page.model.ts b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin-page.model.ts new file mode 100644 index 0000000000..a933a421f9 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin-page.model.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * One activated external-plugin `page` bundle, as returned by `GET /api/v1/external-plugin/menu-pages`. + * The builder lists these under "Plugin pages"; the routed page wrapper renders the resolved + * [bundleUrl]. [titleTranslations] localizes [title]; fall back to [title] then [configurationTitle]. + */ +interface ExternalPluginMenuPage { + configurationId: string; + configurationTitle: string; + bundleKey: string | null; + bundleUrl: string | null; + title: string | null; + titleTranslations: Record; + icon: string | null; +} + +export {ExternalPluginMenuPage}; diff --git a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts new file mode 100644 index 0000000000..197f15831d --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts @@ -0,0 +1,419 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type ExternalPluginHostStatus = 'CONNECTED' | 'UNREACHABLE'; +type ExternalPluginDefinitionStatus = 'AVAILABLE' | 'UNAVAILABLE'; +type ExternalPluginEventQueueMode = 'LIVE' | 'DURABLE'; + +/** + * The kind of remote integration. Both kinds speak the same contract to GZAC; the kind only drives + * the admin UX (labelling, and hiding the upload flow for apps). + * - `PLUGIN_HOST`: a multi-plugin host that plugins are uploaded to. + * - `APP`: a remote service, added by URL, that serves its own single plugin and accepts no uploads. + */ +type ExternalPluginHostKind = 'PLUGIN_HOST' | 'APP'; + +interface ExternalPluginHost { + id: string; + name: string; + baseUrl: string; + kind: ExternalPluginHostKind; + status: ExternalPluginHostStatus; + lastHealthCheck: string | null; + gzacCallbackBaseUrl: string | null; + eventBrokerAmqpUrl: string | null; + eventBrokerExchange: string | null; + eventQueueMode: ExternalPluginEventQueueMode; + eventQueueTtlMs: number | null; +} + +interface ExternalPluginHostCreateRequest { + name: string; + baseUrl: string; + secret: string; + kind: ExternalPluginHostKind; + gzacCallbackBaseUrl: string; + eventBrokerAmqpUrl: string | null; + eventBrokerExchange: string | null; + eventQueueMode: ExternalPluginEventQueueMode; + eventQueueTtlMs: number | null; +} + +interface ExternalPluginHostDefaults { + gzacCallbackBaseUrl: string; + eventBrokerAmqpUrl: string; + eventBrokerExchange: string; + defaultEventQueueTtlMs: number; + minEventQueueTtlMs: number; + maxEventQueueTtlMs: number; +} + +interface ExternalPluginHostEventQueueUpdateRequest { + eventQueueMode: ExternalPluginEventQueueMode; + eventQueueTtlMs: number | null; +} + +interface ExternalPluginAction { + key: string; + title?: string; + description?: string; + /** + * `ActivityTypeWithEventName` names (e.g. `["SERVICE_TASK_START"]`) the action supports. External + * plugin actions are invoked by execution listeners, so this is the set of BPMN activities the + * action may be linked to — a user-task form is the separate `task-form` surface, not an action. + */ + activityTypes?: Array; + /** + * Keys the action's `result` object exposes for mapping. When present and non-empty, the + * process-link stepper offers a dedicated output-mapping step with a dropdown of these keys as + * mapping sources. Actions without `outputs` (or an empty array) have no declared shape and + * cannot use result mapping. + */ + outputs?: Array; +} + +type ExternalPluginFrontendBundleType = + | 'config' + | 'process-link-action' + | 'case-tab' + | 'case-widget' + | 'page' + | 'task-form'; + +interface ExternalPluginFrontendBundle { + type: ExternalPluginFrontendBundleType; + key?: string; + title?: string; + path: string; +} + +interface ExternalPluginEndpoint { + method: string; + pattern: string; +} + +interface ExternalPluginPermissions { + endpoints?: Array; + capabilities?: Array; +} + +interface ExternalPluginManifest { + actions?: Array; + frontendBundles?: Array; + permissions?: ExternalPluginPermissions; + eventSubscriptions?: Array; + logo?: string; + translations?: Record>; +} + +interface ExternalPluginDefinition { + id: string; + pluginId: string; + version: string; + name: string | null; + description: string | null; + provider: string | null; + hostId: string; + baseUrl: string; + status: ExternalPluginDefinitionStatus; + configurationSchema: unknown | null; + manifest: ExternalPluginManifest | null; + /** + * Declared GZAC compatibility bounds (from the manifest) and the resolved outcome of comparing + * them against the running GZAC version. `compatible` is `false` only when the running version + * falls outside the declared range; it stays `true` when the plugin fits, declares no bounds, or + * the running version could not be determined. The management UI surfaces a non-blocking warning + * when `compatible` is `false`. `currentGzacVersion` is the version the check used (null if + * undeterminable). + */ + minGzacVersion: string | null; + maxGzacVersion: string | null; + currentGzacVersion: string | null; + compatible: boolean; + logoUrl: string | null; + /** + * Package content hash pinned when the plugin was discovered, and — when the host started + * serving different bytes under the same pluginId@version — the hash it serves now. While + * `requiresReacceptance` is true the backend withholds configuration pushes, tokens and + * invocations; an admin confirms the reviewed `pendingContentHash` via + * `POST /definition/{id}/accept-content` to resume. + */ + contentHash: string | null; + pendingContentHash: string | null; + requiresReacceptance: boolean; +} + +/** The subset of compatibility fields needed to render a warning message. */ +interface ExternalPluginCompatibilityInfo { + minGzacVersion: string | null; + maxGzacVersion: string | null; + currentGzacVersion: string | null; +} + +interface ExternalPluginConfiguration { + id: string; + definitionId: string; + title: string; + createdAt: string; + /** Revocation counter — bumped by `POST /configuration/{id}/revoke-tokens`. */ + tokenGeneration: number; +} + +/** Response of the downscoped user-token mint endpoint (`.../configuration/{id}/user-token`). */ +interface ExternalPluginUserTokenResponse { + userToken: string; + expiresAt: string; + /** + * The configuration's granted endpoints, so the iframe host can precheck proxied GZAC calls + * client-side (audit-C1). An empty array means the configuration grants nothing (deny-all in the + * precheck); the server-side allowlist remains authoritative either way. + */ + grantedEndpoints: Array; +} + +/** + * Result of an external-plugin task-form submission + * (`.../process-link/{id}/external-plugin-task-form/submission`). Mirrors the backend DTO: a + * submission failed when `errors` or `fieldErrors` is non-empty. + */ +interface ExternalPluginTaskFormSubmissionResult { + errors: string[]; + fieldErrors: Record; + documentId?: string; +} + +interface ExternalPluginGrantedEndpointEntry { + method: string; + pattern: string; +} + +interface ExternalPluginGrantedEndpointResponse { + id: string; + configurationId: string; + httpMethod: string; + endpointPattern: string; + grantedAt: string; +} + +interface ExternalPluginGrantedEventEntry { + eventType: string; +} + +interface ExternalPluginGrantedEventResponse { + id: string; + configurationId: string; + eventType: string; + grantedAt: string; +} + +interface ExternalPluginEndpointDescriptionQuery { + method: string; + pattern: string; +} + +interface ExternalPluginEndpointDescription { + method: string; + pattern: string; + description: string | null; +} + +interface ExternalPluginConfigurationDetail { + id: string; + definitionId: string; + title: string; + properties: Record; + grantedEndpoints: Array; + grantedEvents: Array; + createdAt: string; +} + +interface ExternalPluginConfigurationCreateRequest { + definitionId: string; + title: string; + properties: Record; + grantedEndpoints: Array; + grantedEvents: Array; + grantedCapabilities: Array; +} + +interface ExternalPluginConfigurationUpdateRequest { + title: string; + properties: Record; + grantedEndpoints?: Array; +} + +/** + * What owns the process definition that an `ExternalPluginHostUsage` lives on. `GLOBAL` also + * doubles as the fallback when the process definition can't be resolved at all — in that case + * `parentKey` and `parentVersionTag` are both null. + */ +type ExternalPluginHostUsageParentType = 'CASE' | 'BUILDING_BLOCK' | 'GLOBAL'; + +/** + * One BPMN activity that references a configuration under a plugin host. The host cannot be + * deleted while any of these exist; the management UI uses this payload to disable the delete + * action and tell the admin which case / building block / global process holds the host alive. + */ +interface ExternalPluginHostUsage { + configurationId: string; + configurationTitle: string; + parentType: ExternalPluginHostUsageParentType; + parentKey: string | null; + parentVersionTag: string | null; + // Process-link usages populate these; external-plugin case-tab usages leave them null. + processDefinitionId: string | null; + processDefinitionKey: string | null; + processDefinitionName: string | null; + activityId: string | null; + activityName: string | null; + processLinkId: string | null; + // Populated for an external-plugin case-tab usage, and for an external-plugin case-widget usage + // (where they identify the owning WIDGETS tab). + tabKey?: string | null; + tabName?: string | null; + // Populated only for an external-plugin case-widget usage; names the widget within the tab. + widgetKey?: string | null; + // Populated only for a building-block mapping usage (the BB's pluginConfigurationMappings + // reference the configuration); names the building block holding the mapping. + buildingBlockKey?: string | null; +} + +const EXTERNAL_PLUGIN_KEY_PREFIX = 'external:'; + +function isExternalPluginKey(key: string | undefined | null): boolean { + return !!key?.startsWith(EXTERNAL_PLUGIN_KEY_PREFIX); +} + +function toExternalPluginKey(definitionId: string): string { + return `${EXTERNAL_PLUGIN_KEY_PREFIX}${definitionId}`; +} + +function extractExternalDefinitionId(key: string): string { + return key.replace(EXTERNAL_PLUGIN_KEY_PREFIX, ''); +} + +/** + * Resolves a per-locale manifest string (e.g. `name`, `description`) for the given language, + * falling back to the `en` bucket. A plugin's name and description live in `manifest.translations` + * (there are no top-level fields), so these helpers are the single source of truth for rendering a + * localised name/description anywhere in the management and process-link UIs. + */ +function resolveManifestTranslation( + manifest: ExternalPluginManifest | null | undefined, + key: string, + lang: string +): string | null { + const translations = manifest?.translations; + if (!translations) return null; + const localized = translations[lang]?.[key] ?? translations['en']?.[key]; + return localized && localized.length > 0 ? localized : null; +} + +function getExternalPluginName(definition: ExternalPluginDefinition, lang: string): string { + return ( + resolveManifestTranslation(definition.manifest, 'name', lang) ?? + definition.name ?? + definition.pluginId + ); +} + +function getExternalPluginDescription( + definition: ExternalPluginDefinition, + lang: string +): string | null { + return ( + resolveManifestTranslation(definition.manifest, 'description', lang) ?? definition.description + ); +} + +/** + * Localised plugin name suffixed with the definition version in brackets, e.g. `Case Summary + * (0.1.0)`. Used everywhere a plugin name is rendered so multiple coexisting versions of the same + * plugin stay distinguishable. + */ +function getExternalPluginDisplayName(definition: ExternalPluginDefinition, lang: string): string { + return `${getExternalPluginName(definition, lang)} (${definition.version})`; +} + +/** + * Whether the running GZAC version falls outside the plugin's declared compatibility range. Returns + * `false` for a compatible plugin, a plugin without bounds, or when the version could not be judged + * (the backend reports `compatible: true` in all of those cases). + */ +function isExternalPluginDefinitionIncompatible( + definition: ExternalPluginDefinition | null | undefined +): boolean { + return definition?.compatible === false; +} + +interface PluginLogEntry { + id: number; + level: string; + message: string; + data: Record | null; + source: string; + createdAt: string; +} + +interface PluginLogPage { + content: Array; + page: number; + size: number; + totalElements: number; +} + +export { + EXTERNAL_PLUGIN_KEY_PREFIX, + ExternalPluginAction, + ExternalPluginFrontendBundle, + ExternalPluginFrontendBundleType, + ExternalPluginEndpoint, + ExternalPluginPermissions, + ExternalPluginManifest, + ExternalPluginCompatibilityInfo, + ExternalPluginHostStatus, + ExternalPluginDefinitionStatus, + ExternalPluginEventQueueMode, + ExternalPluginHostKind, + ExternalPluginHost, + ExternalPluginHostCreateRequest, + ExternalPluginHostDefaults, + ExternalPluginHostEventQueueUpdateRequest, + ExternalPluginHostUsage, + ExternalPluginHostUsageParentType, + ExternalPluginDefinition, + ExternalPluginConfiguration, + ExternalPluginUserTokenResponse, + ExternalPluginTaskFormSubmissionResult, + ExternalPluginConfigurationDetail, + ExternalPluginConfigurationCreateRequest, + ExternalPluginConfigurationUpdateRequest, + ExternalPluginGrantedEndpointEntry, + ExternalPluginGrantedEndpointResponse, + ExternalPluginGrantedEventEntry, + ExternalPluginGrantedEventResponse, + ExternalPluginEndpointDescriptionQuery, + ExternalPluginEndpointDescription, + isExternalPluginKey, + toExternalPluginKey, + extractExternalDefinitionId, + getExternalPluginName, + getExternalPluginDescription, + getExternalPluginDisplayName, + isExternalPluginDefinitionIncompatible, + PluginLogEntry, + PluginLogPage, +}; diff --git a/frontend/projects/valtimo/plugin/src/lib/models/index.ts b/frontend/projects/valtimo/plugin/src/lib/models/index.ts index afdc78990c..cc902051bc 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/index.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/index.ts @@ -15,3 +15,5 @@ */ export * from './plugin'; +export * from './external-plugin.model'; +export * from './external-plugin-page.model'; diff --git a/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts b/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts index d7fde502e3..8bca319cb2 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts @@ -104,6 +104,7 @@ interface PluginFunction { description?: string; key: string; title?: string; + outputs?: string[]; } export { diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-page.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-page.service.ts new file mode 100644 index 0000000000..b007065db6 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-page.service.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {BaseApiService, ConfigService} from '@valtimo/shared'; +import {Observable} from 'rxjs'; +import {ExternalPluginMenuPage} from '../models'; + +/** + * Non-management (`/api/v1/...`) client for external-plugin menu pages: the unfiltered list the + * menu-configuration builder offers as the "Plugin pages" catalog category. Minting the downscoped + * user token is the responsibility of the shared `ExternalPluginUserTokenService` (via + * `ExternalPluginSessionService`). + */ +@Injectable({ + providedIn: 'root', +}) +export class ExternalPluginPageService extends BaseApiService { + constructor( + protected readonly httpClient: HttpClient, + protected readonly configService: ConfigService + ) { + super(httpClient, configService); + } + + public getMenuPages(): Observable> { + return this.httpClient.get>( + this.getApiUrl('/v1/external-plugin/menu-pages') + ); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.spec.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.spec.ts new file mode 100644 index 0000000000..9656f2c5d5 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.spec.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {of, throwError} from 'rxjs'; +import {ExternalPluginEndpoint, ExternalPluginUserTokenResponse} from '../models'; +import {ExternalPluginSessionService} from './external-plugin-session.service'; +import {ExternalPluginUserTokenService} from './external-plugin-user-token.service'; + +describe('ExternalPluginSessionService', () => { + let service: ExternalPluginSessionService; + let userTokenServiceSpy: jasmine.SpyObj; + + const grantedEndpoints: Array = [ + {method: 'GET', pattern: '/api/v1/documents/**'}, + ]; + + const tokenResponse = ( + userToken: string, + ttlMs: number, + endpoints: Array = grantedEndpoints + ): ExternalPluginUserTokenResponse => ({ + userToken, + expiresAt: new Date(Date.now() + ttlMs).toISOString(), + grantedEndpoints: endpoints, + }); + + beforeEach(() => { + userTokenServiceSpy = jasmine.createSpyObj( + 'ExternalPluginUserTokenService', + ['mintUserToken'] + ); + + TestBed.configureTestingModule({ + providers: [ + ExternalPluginSessionService, + {provide: ExternalPluginUserTokenService, useValue: userTokenServiceSpy}, + ], + }); + + service = TestBed.inject(ExternalPluginSessionService); + }); + + afterEach(() => { + service.endSession(); + }); + + it('populates the token, expiry and allowed endpoints after a successful mint', () => { + userTokenServiceSpy.mintUserToken.and.returnValue(of(tokenResponse('token-1', 300_000))); + + service.startSession('configuration-1').subscribe(); + + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledWith('configuration-1'); + expect(service.$userToken()).toBe('token-1'); + expect(service.$expiresAt()).not.toBeNull(); + expect(service.$allowedEndpoints()).toEqual(grantedEndpoints); + }); + + it('surfaces an empty granted-endpoint list as an empty array, not undefined', () => { + // The iframe precheck treats an empty allowlist as deny-all and undefined as "skip the + // precheck" — collapsing one into the other would silently disable the guard. + userTokenServiceSpy.mintUserToken.and.returnValue(of(tokenResponse('token-1', 300_000, []))); + + service.startSession('configuration-1').subscribe(); + + expect(service.$allowedEndpoints()).toEqual([]); + }); + + it('propagates a first-mint failure to the caller without populating the token', () => { + userTokenServiceSpy.mintUserToken.and.returnValue(throwError(() => new Error('mint-failed'))); + let failed = false; + + service.startSession('configuration-1').subscribe({error: () => (failed = true)}); + + expect(failed).toBeTrue(); + expect(service.$userToken()).toBeNull(); + expect(service.$allowedEndpoints()).toBeUndefined(); + }); + + it('re-mints before the token expires', fakeAsync(() => { + userTokenServiceSpy.mintUserToken.and.returnValues( + of(tokenResponse('token-1', 300_000)), + of(tokenResponse('token-2', 300_000)) + ); + + service.startSession('configuration-1').subscribe(); + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(1); + + // The re-mint is scheduled at expiry (300s) minus the 60s margin. + tick(240_000 - 1); + expect(service.$userToken()).toBe('token-1'); + + tick(1); + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(2); + expect(service.$userToken()).toBe('token-2'); + + service.endSession(); + })); + + it('retries a failed re-mint with backoff instead of dying silently', fakeAsync(() => { + userTokenServiceSpy.mintUserToken.and.returnValues( + of(tokenResponse('token-1', 300_000)), + throwError(() => new Error('mint-failed')), + throwError(() => new Error('mint-failed')), + of(tokenResponse('token-2', 300_000)) + ); + + service.startSession('configuration-1').subscribe(); + + tick(240_000); // scheduled re-mint fires and fails + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(2); + expect(service.$userToken()).toBe('token-1'); // keeps the previous token while retrying + + tick(5_000); // first retry (5s backoff) fails again + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(3); + + tick(10_000); // second retry (10s backoff) succeeds + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(4); + expect(service.$userToken()).toBe('token-2'); + + service.endSession(); + })); + + it('stops re-minting and clears the token on endSession', fakeAsync(() => { + userTokenServiceSpy.mintUserToken.and.returnValue(of(tokenResponse('token-1', 300_000))); + + service.startSession('configuration-1').subscribe(); + service.endSession(); + + expect(service.$userToken()).toBeNull(); + expect(service.$expiresAt()).toBeNull(); + expect(service.$allowedEndpoints()).toBeUndefined(); + + tick(600_000); + expect(userTokenServiceSpy.mintUserToken).toHaveBeenCalledTimes(1); + })); +}); diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.ts new file mode 100644 index 0000000000..e7b32685bd --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-session.service.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Injectable, OnDestroy, signal} from '@angular/core'; +import {Observable, tap} from 'rxjs'; +import {ExternalPluginEndpoint, ExternalPluginUserTokenResponse} from '../models'; +import {ExternalPluginUserTokenService} from './external-plugin-user-token.service'; + +/** + * Owns the downscoped user-token session for one external-plugin hosting surface (case tab, task + * form or routed page): it mints the token via the shared {@link ExternalPluginUserTokenService}, + * re-mints it before expiry and retries failed re-mints with capped exponential backoff instead of + * letting the session die silently with a token about to expire. + * + * Deliberately declared `@Injectable()` and NOT `providedIn: 'root'` (page-scoped, per the + * page-level orchestrator service convention): each hosting surface provides its own instance in + * its component `providers: []`, so parallel surfaces (e.g. a case tab and a task form open at the + * same time) never share token state, and teardown of the re-mint timer is tied to the hosting + * component's lifecycle via {@link ngOnDestroy}. + */ +@Injectable() +export class ExternalPluginSessionService implements OnDestroy { + /** Re-mint this long before the token expires. */ + private static readonly RE_MINT_MARGIN_MS = 60_000; + /** Never schedule a re-mint sooner than this (guards against very short/expired TTLs). */ + private static readonly MIN_RE_MINT_DELAY_MS = 30_000; + /** First retry delay after a failed re-mint; doubles per attempt up to the cap below. */ + private static readonly INITIAL_RETRY_DELAY_MS = 5_000; + private static readonly MAX_RETRY_DELAY_MS = 60_000; + + private readonly _$userToken = signal(null); + /** Current downscoped user token (null until the first successful mint, and after teardown). */ + public readonly $userToken = this._$userToken.asReadonly(); + + private readonly _$expiresAt = signal(null); + /** Expiry (ISO timestamp) of the current token, or null when no token is held. */ + public readonly $expiresAt = this._$expiresAt.asReadonly(); + + private readonly _$allowedEndpoints = signal | undefined>(undefined); + /** + * The granted endpoints of the current session's configuration, as reported by the mint response + * (audit-C1). Bind this to the iframe's `allowedEndpoints` input: `undefined` until the first + * successful mint (the iframe skips its precheck, the server-side allowlist stays authoritative); + * an empty array means the configuration grants nothing and the precheck denies every call. + */ + public readonly $allowedEndpoints = this._$allowedEndpoints.asReadonly(); + + private _configurationId: string | null = null; + private _reMintHandle: number | null = null; + private _retryDelayMs = ExternalPluginSessionService.INITIAL_RETRY_DELAY_MS; + + constructor(private readonly userTokenService: ExternalPluginUserTokenService) {} + + public ngOnDestroy(): void { + this.endSession(); + } + + /** + * Starts (or restarts) the token session for the given configuration. Returns the observable of + * the *first* mint so the caller can gate its own ready/error handling on it (e.g. a task form + * renders anyway when no token can be minted). Subsequent re-mints — and retries of failed + * re-mints — are handled internally for as long as the session is active. + */ + public startSession(configurationId: string): Observable { + this.endSession(); + this._configurationId = configurationId; + return this.userTokenService + .mintUserToken(configurationId) + .pipe(tap(token => this._onToken(token))); + } + + /** Stops re-minting, clears the timer and drops the token. Also called from ngOnDestroy. */ + public endSession(): void { + this._clearReMint(); + this._configurationId = null; + this._retryDelayMs = ExternalPluginSessionService.INITIAL_RETRY_DELAY_MS; + this._$userToken.set(null); + this._$expiresAt.set(null); + this._$allowedEndpoints.set(undefined); + } + + private _onToken(token: ExternalPluginUserTokenResponse): void { + this._$userToken.set(token.userToken); + this._$expiresAt.set(token.expiresAt); + this._$allowedEndpoints.set(token.grantedEndpoints); + this._retryDelayMs = ExternalPluginSessionService.INITIAL_RETRY_DELAY_MS; + this._scheduleReMint(token.expiresAt); + } + + private _scheduleReMint(expiresAt: string): void { + this._clearReMint(); + const expiry = new Date(expiresAt).getTime(); + const delay = Math.max( + expiry - Date.now() - ExternalPluginSessionService.RE_MINT_MARGIN_MS, + ExternalPluginSessionService.MIN_RE_MINT_DELAY_MS + ); + this._reMintHandle = window.setTimeout(() => this._reMint(), delay); + } + + private _reMint(): void { + const configurationId = this._configurationId; + if (!configurationId) return; + + this.userTokenService.mintUserToken(configurationId).subscribe({ + next: token => this._onToken(token), + // Retry with capped exponential backoff (5s → 10s → 20s → … ≤ 60s) instead of dying + // silently: the surface stays open, so keep trying to restore the session. The global + // HttpErrorInterceptor already surfaces the failed call to the user. + error: () => this._scheduleRetry(), + }); + } + + private _scheduleRetry(): void { + this._clearReMint(); + this._reMintHandle = window.setTimeout(() => this._reMint(), this._retryDelayMs); + this._retryDelayMs = Math.min( + this._retryDelayMs * 2, + ExternalPluginSessionService.MAX_RETRY_DELAY_MS + ); + } + + private _clearReMint(): void { + if (this._reMintHandle !== null) { + window.clearTimeout(this._reMintHandle); + this._reMintHandle = null; + } + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-task-form-submission.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-task-form-submission.service.ts new file mode 100644 index 0000000000..58acd8def4 --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-task-form-submission.service.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {ConfigService} from '@valtimo/shared'; +import {Observable} from 'rxjs'; +import {ExternalPluginTaskFormSubmissionResult} from '../models'; + +/** + * Submits an external-plugin `task-form`'s collected data to GZAC, which completes the user task the + * standard way (value resolvers, document updates, `TaskCompleted` event) — no plugin backend code, + * no endpoint grant and no downscoped user token required for the common case. The Angular parent + * calls this (not the iframe) under the logged-in user's Keycloak session, so the normal COMPLETE + * permission governs the task. The authoritative `taskInstanceId` comes from the process-link result, + * never from the iframe. + */ +@Injectable({ + providedIn: 'root', +}) +export class ExternalPluginTaskFormSubmissionService { + private readonly _baseUrl: string; + + constructor( + private readonly _http: HttpClient, + configService: ConfigService + ) { + this._baseUrl = `${configService.config.valtimoApi.endpointUri}v1/process-link`; + } + + public submit( + processLinkId: string, + data: Record, + documentId?: string | null, + taskInstanceId?: string | null + ): Observable { + let params = new HttpParams(); + if (documentId) params = params.set('documentId', documentId); + if (taskInstanceId) params = params.set('taskInstanceId', taskInstanceId); + + return this._http.post( + `${this._baseUrl}/${processLinkId}/external-plugin-task-form/submission`, + data, + {params} + ); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-user-token.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-user-token.service.ts new file mode 100644 index 0000000000..eff11683da --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin-user-token.service.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {ConfigService} from '@valtimo/shared'; +import {Observable} from 'rxjs'; +import {ExternalPluginUserTokenResponse} from '../models'; + +/** + * Mints the short-lived, downscoped user token used by every external-plugin iframe surface + * (case tabs, task forms, …) to call GZAC on behalf of the logged-in user. Shared here so each + * surface does not reimplement the mint call. `HttpClient` is used deliberately so the Keycloak + * bearer interceptor authenticates the mint as the current user — the result is bounded by + * PBAC ∩ the plugin's granted-endpoint allowlist and a ≤15-minute TTL. + */ +@Injectable({ + providedIn: 'root', +}) +export class ExternalPluginUserTokenService { + private readonly _baseUrl: string; + + constructor( + private readonly _http: HttpClient, + configService: ConfigService + ) { + this._baseUrl = `${configService.config.valtimoApi.endpointUri}v1/external-plugin`; + } + + public mintUserToken(configurationId: string): Observable { + return this._http.post( + `${this._baseUrl}/configuration/${configurationId}/user-token`, + {} + ); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts new file mode 100644 index 0000000000..5fef87f8ce --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Injectable} from '@angular/core'; +import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; +import {ConfigService, InterceptorSkip} from '@valtimo/shared'; +import {Observable} from 'rxjs'; +import { + ExternalPluginConfiguration, + ExternalPluginConfigurationCreateRequest, + ExternalPluginConfigurationDetail, + ExternalPluginConfigurationUpdateRequest, + ExternalPluginDefinition, + ExternalPluginEndpointDescription, + ExternalPluginEndpointDescriptionQuery, + ExternalPluginHost, + ExternalPluginHostCreateRequest, + ExternalPluginHostDefaults, + ExternalPluginHostEventQueueUpdateRequest, + ExternalPluginHostUsage, + PluginLogPage, +} from '../models'; + +@Injectable({ + providedIn: 'root', +}) +export class ExternalPluginService { + private readonly _baseUrl: string; + + constructor( + private readonly _http: HttpClient, + configService: ConfigService + ) { + this._baseUrl = `${configService.config.valtimoApi.endpointUri}management/v1/external-plugin`; + } + + public getHosts(): Observable> { + return this._http.get>(`${this._baseUrl}/host`); + } + + public createHost(request: ExternalPluginHostCreateRequest): Observable { + return this._http.post(`${this._baseUrl}/host`, request); + } + + public getHostDefaults(): Observable { + return this._http.get(`${this._baseUrl}/host-defaults`); + } + + public deleteHost(hostId: string): Observable { + return this._http.delete(`${this._baseUrl}/host/${hostId}`); + } + + /** + * Returns what currently references any configuration under the host — BPMN process links, + * external-plugin case tabs and case widgets, and building-block mappings. + * Empty list = safe to delete. A non-empty list is also what the backend will attach to a + * 409 if the user tries to delete anyway, so the UI uses this proactively to disable the + * delete action. + */ + public getHostUsages(hostId: string): Observable> { + return this._http.get>(`${this._baseUrl}/host/${hostId}/usages`); + } + + public updateHostEventQueue( + hostId: string, + request: ExternalPluginHostEventQueueUpdateRequest + ): Observable { + return this._http.patch( + `${this._baseUrl}/host/${hostId}/event-queue`, + request + ); + } + + public getDefinitions(): Observable> { + return this._http.get>(`${this._baseUrl}/definition`); + } + + public getDefinition(definitionId: string): Observable { + return this._http.get(`${this._baseUrl}/definition/${definitionId}`); + } + + public getConfiguration(configurationId: string): Observable { + return this._http.get( + `${this._baseUrl}/configuration/${configurationId}` + ); + } + + public getConfigurations(definitionId?: string): Observable> { + let params = new HttpParams(); + if (definitionId) params = params.set('definitionId', definitionId); + return this._http.get>(`${this._baseUrl}/configuration`, { + params, + }); + } + + public createConfiguration( + request: ExternalPluginConfigurationCreateRequest + ): Observable { + return this._http.post(`${this._baseUrl}/configuration`, request); + } + + public updateConfiguration( + configurationId: string, + request: ExternalPluginConfigurationUpdateRequest + ): Observable { + return this._http.put( + `${this._baseUrl}/configuration/${configurationId}`, + request + ); + } + + public deleteConfiguration(configurationId: string): Observable { + return this._http.delete(`${this._baseUrl}/configuration/${configurationId}`); + } + + public getConfigurationLogs( + configurationId: string, + params: {page: number; size: number; level?: string; source?: string} + ): Observable { + let httpParams = new HttpParams() + .set('page', params.page.toString()) + .set('size', params.size.toString()); + if (params.level) httpParams = httpParams.set('level', params.level); + if (params.source) httpParams = httpParams.set('source', params.source); + return this._http.get( + `${this._baseUrl}/configuration/${configurationId}/logs`, + {params: httpParams} + ); + } + + /** + * Same shape as [getHostUsages] but scoped to a single configuration. Empty list = safe to + * delete. Non-empty = the configuration is referenced by one or more process links, case tabs, + * case widgets or building-block mappings, and `deleteConfiguration` would fail with a 409 + * carrying these same entries. + */ + public getConfigurationUsages( + configurationId: string + ): Observable> { + return this._http.get>( + `${this._baseUrl}/configuration/${configurationId}/usages` + ); + } + + public getEndpointDescriptions( + endpoints: Array, + locale: string = 'en' + ): Observable> { + const params = new HttpParams().set('locale', locale); + return this._http.post>( + `${this._baseUrl}/endpoint-descriptions`, + endpoints, + {params} + ); + } + + /** + * Uploads a plugin package to the host. Two expected 409s drive the upload UX (both kept off + * the global error toast by the `X-Skip-Interceptor` header): an incompatible plugin returns + * the version details and is retried with `force=true` after the operator confirms, and an + * already-existing pluginId@version returns `code=PLUGIN_VERSION_EXISTS` plus the package's + * requested permissions and is retried with `overwrite=true` after the operator re-reviews the + * permissions and confirms the overwrite. + */ + public uploadPlugin(hostId: string, file: File, force = false, overwrite = false): Observable { + const formData = new FormData(); + formData.append('file', file, file.name); + const params = new HttpParams().set('force', force).set('overwrite', overwrite); + const headers = new HttpHeaders().set(InterceptorSkip, '409'); + return this._http.post(`${this._baseUrl}/host/${hostId}/upload`, formData, {headers, params}); + } +} diff --git a/frontend/projects/valtimo/plugin/src/lib/services/index.ts b/frontend/projects/valtimo/plugin/src/lib/services/index.ts index bb18e309e6..0e36de8bc5 100644 --- a/frontend/projects/valtimo/plugin/src/lib/services/index.ts +++ b/frontend/projects/valtimo/plugin/src/lib/services/index.ts @@ -17,3 +17,8 @@ export * from './plugin.service'; export * from './plugin-translation.service'; export * from './plugin-management.service'; +export * from './external-plugin.service'; +export * from './external-plugin-user-token.service'; +export * from './external-plugin-session.service'; +export * from './external-plugin-task-form-submission.service'; +export * from './external-plugin-page.service'; diff --git a/frontend/projects/valtimo/plugin/src/lib/services/plugin-management.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/plugin-management.service.ts index 6c88acd4ac..54b581dcb1 100644 --- a/frontend/projects/valtimo/plugin/src/lib/services/plugin-management.service.ts +++ b/frontend/projects/valtimo/plugin/src/lib/services/plugin-management.service.ts @@ -18,6 +18,7 @@ import {Injectable} from '@angular/core'; import {HttpClient, HttpParams} from '@angular/common/http'; import {combineLatest, Observable} from 'rxjs'; import { + ExternalPluginHostUsage, PluginConfiguration, PluginConfigurationWithLogo, PluginDefinition, @@ -131,6 +132,19 @@ export class PluginManagementService { ); } + /** + * Mirrors `ExternalPluginService.getConfigurationUsages` but for embedded plugin + * configurations. Empty list = safe to delete. Non-empty = `deletePluginConfiguration` + * will return a 409 carrying the same entries. + */ + public getConfigurationUsages( + configurationId: string + ): Observable> { + return this.http.get>( + `${this.VALTIMO_API_ENDPOINT_URI}v1/plugin/configuration/${configurationId}/usages` + ); + } + private returnPluginConfigurationsWithLogos( pluginConfigurations$: Observable> ): Observable> { diff --git a/frontend/projects/valtimo/plugin/src/lib/utils/external-plugin-data-url.utils.ts b/frontend/projects/valtimo/plugin/src/lib/utils/external-plugin-data-url.utils.ts new file mode 100644 index 0000000000..fdc34d8efa --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/utils/external-plugin-data-url.utils.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Derives the plugin host data route (`{base}/data`) from a bundle URL + * (`{base}/bundles/.html`) — the route that backs `target: "plugin"` proxy requests. + * Returns null when the URL does not follow the bundle layout. Shared by every external-plugin + * hosting surface (case tab, task form, routed page). + */ +const derivePluginDataUrl = (bundleUrl: string | null): string | null => { + if (!bundleUrl) return null; + const idx = bundleUrl.indexOf('/bundles/'); + return idx >= 0 ? `${bundleUrl.substring(0, idx)}/data` : null; +}; + +export {derivePluginDataUrl}; diff --git a/frontend/projects/valtimo/plugin/src/lib/utils/index.ts b/frontend/projects/valtimo/plugin/src/lib/utils/index.ts new file mode 100644 index 0000000000..e6115a4c5d --- /dev/null +++ b/frontend/projects/valtimo/plugin/src/lib/utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './external-plugin-data-url.utils'; diff --git a/frontend/projects/valtimo/plugin/src/public-api.ts b/frontend/projects/valtimo/plugin/src/public-api.ts index 85b0d45056..4a15388329 100644 --- a/frontend/projects/valtimo/plugin/src/public-api.ts +++ b/frontend/projects/valtimo/plugin/src/public-api.ts @@ -22,9 +22,15 @@ export * from './lib/services'; export * from './lib/models'; export * from './lib/pipes'; export * from './lib/constants'; +export * from './lib/utils'; /* plugin configuration container */ export * from './lib/components/plugin-configuration-container/plugin-configuration-container.component'; export * from './lib/components/plugin-configuration-container/plugin-configuration-container.module'; +/* external plugin iframe */ +export * from './lib/components/external-plugin-iframe/external-plugin-iframe.component'; +/* external plugin routed page */ +export * from './lib/components/external-plugin-page/external-plugin-page.component'; +export * from './lib/external-plugin-page-routing.module'; /* open-zaak plugin */ export * from './lib/plugins/open-zaak/open-zaak-plugin.module'; export * from './lib/plugins/open-zaak/components/open-zaak-configuration/open-zaak-configuration.component'; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html index e551abb8d3..01adca853a 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html @@ -78,17 +78,39 @@ {{ plugin.label }}
- - - +
+ + + + + @if (plugin.selectedConfigurationVersion) { +
+ +
+ } +

} } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss index 178f513d6d..67a0a65fcd 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss @@ -32,6 +32,20 @@ margin-top: 30px; } +.plugin-configuration-select { + &__warning { + display: flex; + width: 100%; + margin-top: var(--cds-spacing-03); + + ::ng-deep cds-inline-notification { + width: 100%; + max-inline-size: unset; + margin: 0; + } + } +} + .configure-building-block-plugins__warning { display: flex; width: 100%; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts index 27ef88f6d1..2bc363f8b8 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts @@ -24,6 +24,10 @@ import { } from '../../services'; import {BuildingBlockStateService} from '../../services/building-block-state.service'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -34,6 +38,7 @@ import { PluginConfigurationViewModel, ProcessLink, ProcessLinkType, + RequiredPlugin, } from '../../models'; import {combineLatest, distinctUntilChanged, Observable, of, shareReplay, Subscription} from 'rxjs'; import {catchError, filter, map, switchMap, take, withLatestFrom} from 'rxjs/operators'; @@ -48,7 +53,7 @@ import {NotificationContent} from 'carbon-components-angular'; styleUrls: ['./configure-building-block-plugins.component.scss'], }) export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy { - public readonly pluginKeys$ = this.buildingBlockStateService.requiredPluginKeys$; + public readonly requiredPlugins$ = this.buildingBlockStateService.requiredPlugins$; public readonly isNestedBuildingBlock$ = this.buildingBlockStateService.isNestedBuildingBlock$; private readonly _pluginDependenciesWarningTranslationKey$: Observable = this.buildingBlockStateService.pluginDependencies$.pipe( @@ -122,28 +127,38 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy ); public readonly pluginConfigurationViewModels$: Observable> = combineLatest([ - this.pluginKeys$, + this.requiredPlugins$, this.buildingBlockStateService.pluginMappings$, this.configurationPlaceholder$, + this.translateService.stream('key'), ]).pipe( - switchMap(([pluginKeys, pluginMappings, placeholder]) => { - if (!pluginKeys?.length) { + switchMap(([requiredPlugins, pluginMappings, placeholder]) => { + if (!requiredPlugins?.length) { return of([]); } return combineLatest( - pluginKeys.map(pluginKey => - this.getConfigurationOptions(pluginKey).pipe( - map(options => ({ - key: pluginKey, - label: this.pluginLabel(pluginKey), - dropdownItems: this.buildDropdownItems( - options, - pluginMappings?.[pluginKey], - placeholder - ), - hasOptions: options.length > 0, - })) + requiredPlugins.map(requiredPlugin => + (requiredPlugin.source === 'EXTERNAL' + ? this.getExternalConfigurationOptions(requiredPlugin) + : this.getEmbeddedConfigurationOptions(requiredPlugin).pipe( + map(options => ({options, mismatchedVersionsById: new Map()})) + ) + ).pipe( + map(({options, mismatchedVersionsById}) => { + const selectedId = pluginMappings?.[requiredPlugin.mappingKey]; + return { + key: requiredPlugin.mappingKey, + label: this.pluginLabel(requiredPlugin), + dropdownItems: this.buildDropdownItems(options, selectedId, placeholder), + hasOptions: options.length > 0, + source: requiredPlugin.source, + pluginDefinitionVersion: requiredPlugin.pluginDefinitionVersion, + selectedConfigurationVersion: selectedId + ? mismatchedVersionsById.get(selectedId) + : undefined, + } as PluginConfigurationViewModel; + }) ) ) ); @@ -155,6 +170,10 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy string, Observable> >(); + private readonly _externalConfigurationOptionsCache = new Map< + string, + Observable<{options: Array; mismatchedVersionsById: Map}> + >(); constructor( private readonly stateService: ProcessLinkStateService, @@ -165,7 +184,8 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy private readonly pluginTranslationService: PluginTranslationService, private readonly processLinkService: ProcessLinkService, private readonly translateService: TranslateService, - private readonly processLinkBuildingBlockApiService: ProcessLinkBuildingBlockApiService + private readonly processLinkBuildingBlockApiService: ProcessLinkBuildingBlockApiService, + private readonly externalPluginService: ExternalPluginService ) {} public ngOnInit(): void { @@ -236,9 +256,10 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy this._subscriptions.unsubscribe(); } - private getConfigurationOptions( - pluginDefinitionKey: string + private getEmbeddedConfigurationOptions( + requiredPlugin: RequiredPlugin ): Observable> { + const pluginDefinitionKey = requiredPlugin.pluginDefinitionKey; if (!this._configurationOptionsCache.has(pluginDefinitionKey)) { this._configurationOptionsCache.set( pluginDefinitionKey, @@ -253,17 +274,79 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy return this._configurationOptionsCache.get(pluginDefinitionKey) ?? of([]); } - public onMappingChange(pluginDefinitionKey: string, configurationId: string): void { - const normalizedValue = configurationId || null; - this.buildingBlockStateService.setPluginConfigurationMapping( - pluginDefinitionKey, - normalizedValue + /** + * Activated external configurations for the required plugin's `pluginId`. Configurations + * matching `pluginId@version` exactly are offered as normal options; configurations of the same + * `pluginId` at a different version are still offered (selectable), with their actual definition + * version recorded in `mismatchedVersionsById` so the template can render the D3 non-blocking + * warning when such a configuration is selected. + */ + private getExternalConfigurationOptions( + requiredPlugin: RequiredPlugin + ): Observable<{options: Array; mismatchedVersionsById: Map}> { + const cacheKey = requiredPlugin.mappingKey; + if (!this._externalConfigurationOptionsCache.has(cacheKey)) { + this._externalConfigurationOptionsCache.set( + cacheKey, + combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === requiredPlugin.pluginDefinitionKey; + }); + + const lang = this.translateService.currentLang; + const mismatchedVersionsById = new Map(); + const options: Array = matchingConfigurations.map( + configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== requiredPlugin.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + id: configuration.id, + title: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + properties: {}, + } as PluginConfiguration; + } + ); + + return {options, mismatchedVersionsById}; + }), + shareReplay(1) + ) + ); + } + return ( + this._externalConfigurationOptionsCache.get(cacheKey) ?? + of({options: [], mismatchedVersionsById: new Map()}) ); } - public pluginLabel(pluginDefinitionKey: string): string { + public onMappingChange(mappingKey: string, configurationId: string): void { + const normalizedValue = configurationId || null; + this.buildingBlockStateService.setPluginConfigurationMapping(mappingKey, normalizedValue); + } + + public pluginLabel(requiredPlugin: RequiredPlugin): string { + if (requiredPlugin.source === 'EXTERNAL') { + return requiredPlugin.pluginDefinitionVersion + ? `${requiredPlugin.pluginDefinitionKey} (${requiredPlugin.pluginDefinitionVersion})` + : requiredPlugin.pluginDefinitionKey; + } return ( - this.pluginTranslationService.instant('title', pluginDefinitionKey) || pluginDefinitionKey + this.pluginTranslationService.instant('title', requiredPlugin.pluginDefinitionKey) || + requiredPlugin.pluginDefinitionKey ); } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html index 9c6b37766c..e756840301 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html @@ -1,5 +1,5 @@ - + + + + - + + + + +

+ {{ 'processLinkConfiguration.externalPluginTaskForm.description' | translate }} +

+
+ + + + + +
+

+ {{ 'processLinkConfiguration.externalPluginActionProperties' | translate }} +

+
+ + +
+ {{ 'processLinkConfiguration.invalidJson' | translate }} +
+
+
+
+
+
+ + + + + + +
diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.scss index d44c71f19c..6ddaa012b8 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.scss +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.scss @@ -1,5 +1,5 @@ /*! - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -13,3 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +.external-action-config { + .cds--text-area { + font-family: monospace; + width: 100%; + } +} diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts index 8a1b42704c..f4fb9311cd 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 Ritense BV, the Netherlands. + * Copyright 2015-2026 Ritense BV, the Netherlands. * * Licensed under EUPL, Version 1.2 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {FormControl} from '@angular/forms'; import { PluginStateService, ProcessLinkButtonService, @@ -22,15 +23,39 @@ import { ProcessLinkStateService, ProcessLinkStepService, } from '../../services'; -import {BehaviorSubject, combineLatest, Observable, Subscription} from 'rxjs'; -import {filter, map, take, withLatestFrom} from 'rxjs/operators'; -import {PluginConfiguration, PluginConfigurationData} from '@valtimo/plugin'; +import {BehaviorSubject, combineLatest, Observable, of, Subscription} from 'rxjs'; import { + catchError, + filter, + map, + shareReplay, + switchMap, + take, + withLatestFrom, +} from 'rxjs/operators'; +import { + ExternalPluginDefinition, + ExternalPluginService, + extractExternalDefinitionId, + isExternalPluginKey, + PluginConfiguration, + PluginConfigurationData, + PluginFunction, +} from '@valtimo/plugin'; +import { + ExternalPluginProcessLinkCreateDto, + ExternalPluginProcessLinkUpdateDto, + ExternalPluginTaskFormProcessLinkCreateDto, + ExternalPluginTaskFormProcessLinkUpdateDto, + PluginActionResultMapping, PluginConfigurationReferenceType, PluginProcessLinkCreateDto, PluginProcessLinkUpdateDto, ProcessLink, } from '../../models'; +import {USER_TASK_ACTIVITY} from '../../constants'; +import {ActivatedRoute} from '@angular/router'; +import {getBuildingBlockManagementRouteParams, getCaseManagementRouteParams} from '@valtimo/shared'; @Component({ standalone: false, @@ -39,23 +64,124 @@ import { styleUrls: ['./plugin-action-configuration.component.scss'], }) export class PluginActionConfigurationComponent implements OnInit, OnDestroy { - @Input() selectedPluginConfiguration$: Observable; - @Output() valid: EventEmitter = new EventEmitter(); - @Output() configuration: EventEmitter = + @Input() public selectedPluginConfiguration$: Observable; + @Output() public valid: EventEmitter = new EventEmitter(); + @Output() public configuration: EventEmitter = new EventEmitter(); - public readonly pluginDefinitionKey$ = this.pluginStateService.pluginDefinitionKey$; - public readonly functionKey$ = this.pluginStateService.functionKey$; - public readonly save$ = this.pluginStateService.save$; - public readonly saving$ = this.stateService.saving$; + public readonly pluginDefinitionKey$ = this._pluginStateService.pluginDefinitionKey$; + public readonly functionKey$ = this._pluginStateService.functionKey$; + public readonly save$ = this._pluginStateService.save$; + public readonly saving$ = this._stateService.saving$; + + public readonly isExternalPlugin$: Observable = + this._pluginStateService.selectedPluginDefinition$.pipe( + map(definition => isExternalPluginKey(definition?.key)) + ); + + public readonly currentStepId$ = this._stepService.currentStepId$; + public readonly selectedFunction$: Observable = + this._pluginStateService.selectedPluginFunction$; + + /** + * The selected external action's declared `outputs`, resolved from the selected function when + * available and falling back to a manifest lookup (edit mode: `loadExternalPluginStateForProcessLink` + * only sets the selected function's `key`, not its `outputs`). Passed to the mappings editor as + * `sourceKeys` — a dropdown of these keys replaces the free-text pointer input. + */ + public readonly selectedFunctionOutputs$: Observable> = combineLatest([ + this.isExternalPlugin$, + this._pluginStateService.selectedPluginDefinition$, + this.selectedFunction$, + this._stateService.selectedProcessLink$, + ]).pipe( + switchMap(([isExternal, definition, selectedFunction, selectedProcessLink]) => { + if (!isExternal || !definition?.key) return of([]); + if (selectedFunction?.outputs?.length) return of(selectedFunction.outputs); + + const actionKey = selectedFunction?.key || selectedProcessLink?.actionKey; + if (!actionKey) return of([]); + + const definitionId = extractExternalDefinitionId(definition.key); + return this._externalPluginService.getDefinition(definitionId).pipe( + map((extDef: ExternalPluginDefinition) => { + const action = extDef.manifest?.actions?.find(a => a.key === actionKey); + return action?.outputs ?? []; + }), + // A failed manifest lookup must not kill the outer stream — fall back to "no outputs". + catchError(() => of([] as Array)) + ); + }), + shareReplay({bufferSize: 1, refCount: true}) + ); + + /** + * True when the selected external action declares `outputs` in its manifest — the only case the + * dedicated output-mapping step (and its Next/Save button swap) applies. Embedded actions have + * no declaration mechanism and never carry this. + */ + public readonly hasResultMappingsStep$: Observable = this.selectedFunctionOutputs$.pipe( + map(outputs => outputs.length > 0) + ); + + /** + * True when the external plugin is being linked to a user task: the plugin contributes a + * `task-form` (rendered + completed by the plugin), not a service-task action. In that case this + * step has nothing to configure — the selected form is saved as an `external_plugin_task_form` + * link. Derived from the edited link's type (edit) or the activity being configured (create). + */ + public readonly isTaskForm$: Observable = combineLatest([ + this.isExternalPlugin$, + this._stateService.modalParams$, + this._stateService.selectedProcessLink$, + ]).pipe( + map(([isExternal, modalParams, selectedProcessLink]) => + selectedProcessLink + ? selectedProcessLink.processLinkType === 'external_plugin_task_form' + : isExternal && modalParams?.element?.activityListenerType === USER_TASK_ACTIVITY + ) + ); + + public externalActionProperties: Record = {}; + public externalActionPropertiesValid = true; + + /** Reactive control backing the raw-JSON textarea fallback (no iframe bundle available). */ + public readonly externalActionPropertiesControl = new FormControl('{}', { + nonNullable: true, + }); + + /** + * `actionResultMappings` (#771): row-based JSON-pointer -> value-resolver-target write-back + * rules for the action's return value, kept by `PluginActionResultMappingsComponent` and read + * here purely as a plain value (that component owns its own form state). + */ + public actionResultMappings: Array = []; + + private readonly _resultMappingsValid$ = new BehaviorSubject(true); + + /** URL for the process-link-action iframe bundle: undefined = loading, null = no bundle, string = bundle URL */ + public readonly externalActionBundleUrl$ = new BehaviorSubject( + undefined + ); + + /** Emits true once the bundle URL lookup has completed */ + public readonly externalBundleResolved$: Observable = this.externalActionBundleUrl$.pipe( + map(url => url !== undefined) + ); + + /** Prefill data for the iframe when editing an existing process link */ + public readonly externalActionPrefill$ = new BehaviorSubject<{ + title: string; + configuration: Record; + } | null>(null); private readonly _prefillConfigurationSubject$ = new BehaviorSubject< ProcessLink['actionProperties'] | null >(null); // Only prefill if the action key hasn't changed from what's saved in the process link private readonly _prefillConfiguration$ = combineLatest([ - this.stateService.selectedProcessLink$, - this.pluginStateService.selectedPluginFunction$, + this._stateService.selectedProcessLink$, + this._pluginStateService.selectedPluginFunction$, ]).pipe( map(([processLink, selectedFunction]) => { if (!processLink) return undefined; @@ -78,19 +204,80 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { ) ); + /** Case/building-block route context, forwarded to the result-mapping target's value-path selector. */ + public readonly caseParams$ = getCaseManagementRouteParams(this._route); + public readonly buildingBlockParams$ = getBuildingBlockManagementRouteParams(this._route); + private _subscriptions = new Subscription(); constructor( - private readonly stateService: ProcessLinkStateService, - private readonly pluginStateService: PluginStateService, - private readonly buttonService: ProcessLinkButtonService, - private readonly stepService: ProcessLinkStepService, - private readonly processLinkService: ProcessLinkService + private readonly _stateService: ProcessLinkStateService, + private readonly _pluginStateService: PluginStateService, + private readonly _buttonService: ProcessLinkButtonService, + private readonly _stepService: ProcessLinkStepService, + private readonly _processLinkService: ProcessLinkService, + private readonly _externalPluginService: ExternalPluginService, + private readonly _route: ActivatedRoute ) {} ngOnInit(): void { this.openBackButtonSubscription(); this.openSaveButtonSubscription(); + this.openNextButtonSubscription(); + this.openMappingsBackButtonSubscription(); + + this._subscriptions.add( + combineLatest([this.isExternalPlugin$, this.hasResultMappingsStep$]).subscribe( + ([isExternal, hasResultMappingsStep]) => { + // A pending output-mapping step keeps Next/Save handled by + // `setConfigurePluginActionSteps()`/`setConfigurePluginActionResultMappingsSteps()` — do + // not race that with an unconditional enableSaveButton() here. Save is only enabled when + // the current action configuration is valid — an invalid configuration must never be + // saveable (subsequent validity changes are handled by `applyActionPropertiesValidity`). + if (isExternal && !hasResultMappingsStep && this.externalActionPropertiesValid) { + this._buttonService.enableSaveButton(); + } + } + ) + ); + + this._subscriptions.add( + this.externalActionPropertiesControl.valueChanges.subscribe(value => { + this.handleExternalActionPropertiesChange(value); + }) + ); + + this._subscriptions.add( + // Save on the mappings step is gated only by the rows' own validity (zero rows is valid; + // every present row needs both source and target). The action properties were already + // validated to reach this step — their validity gated the Next button — and neither the + // create path (step service shows Save without enabling it) nor edit mode (the generic edit + // navigation only toggles visibility) enables Save on arrival. + combineLatest([this.currentStepId$, this._resultMappingsValid$]) + .pipe(filter(([stepId]) => stepId === 'configurePluginActionResultMappings')) + .subscribe(([, mappingsValid]) => { + if (mappingsValid) { + this._buttonService.enableSaveButton(); + } else { + this._buttonService.disableSaveButton(); + } + }) + ); + + this._subscriptions.add( + this._stateService.selectedProcessLink$.pipe(take(1)).subscribe(processLink => { + if (processLink?.actionProperties) { + const json = JSON.stringify(processLink.actionProperties, null, 2); + // Programmatic update: properties/validity are set directly, so skip re-parsing. + this.externalActionPropertiesControl.setValue(json, {emitEvent: false}); + this.externalActionProperties = processLink.actionProperties; + } + this.actionResultMappings = processLink?.actionResultMappings ?? []; + }) + ); + + this.openEditModeResultMappingsSubscription(); + this.resolveExternalActionBundleUrl(); } ngOnDestroy(): void { @@ -98,14 +285,14 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { } onValid(valid: boolean): void { - if (valid) this.buttonService.enableSaveButton(); - else this.buttonService.disableSaveButton(); + if (valid) this._buttonService.enableSaveButton(); + else this._buttonService.disableSaveButton(); } onConfiguration(configuration: PluginConfigurationData): void { - this.stateService.startSaving(); + this._stateService.startSaving(); - this.stateService.selectedProcessLink$.pipe(take(1)).subscribe(selectedProcessLink => { + this._stateService.selectedProcessLink$.pipe(take(1)).subscribe(selectedProcessLink => { if (selectedProcessLink) { this.updateProcessLink(configuration); } else { @@ -118,10 +305,139 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { this._prefillConfigurationSubject$.next(configuration); } + private handleExternalActionPropertiesChange(value: string): void { + try { + this.externalActionProperties = JSON.parse(value); + this.externalActionPropertiesValid = true; + } catch { + this.externalActionPropertiesValid = false; + } + this.applyActionPropertiesValidity(); + } + + public onIframeConfigurationChanged(event: { + valid: boolean; + title: string; + data: Record; + }): void { + this.externalActionProperties = event.data; + // Programmatic update: properties/validity are set directly, so skip re-parsing. + this.externalActionPropertiesControl.setValue(JSON.stringify(event.data, null, 2), { + emitEvent: false, + }); + this.externalActionPropertiesValid = event.valid; + this.applyActionPropertiesValidity(); + } + + /** + * The action-properties step's validity gates whichever button is visible on it: `Save` when the + * action has no pending output-mapping step, `Next` when it does (Save only appears on the + * mappings step in that case — see {@link openNextButtonSubscription}). + */ + private applyActionPropertiesValidity(): void { + this.hasResultMappingsStep$.pipe(take(1)).subscribe(hasResultMappingsStep => { + const toggle = hasResultMappingsStep + ? ['enableNextButton', 'disableNextButton'] + : ['enableSaveButton', 'disableSaveButton']; + this._buttonService[this.externalActionPropertiesValid ? toggle[0] : toggle[1]](); + }); + } + + public onActionResultMappingsChange(mappings: Array): void { + this.actionResultMappings = mappings; + } + + public onActionResultMappingsValidityChange(valid: boolean): void { + this._resultMappingsValid$.next(valid); + } + + /** + * Edit mode opens directly on the last step, bypassing `setConfigurePluginActionSteps()` (which + * normally decides whether the mappings step exists). `loadExternalPluginStateForProcessLink` + * only sets the selected function's `key` (no `outputs`), so this relies on + * {@link selectedFunctionOutputs$}'s manifest-lookup fallback to find the declared `outputs`. + */ + private openEditModeResultMappingsSubscription(): void { + this._subscriptions.add( + combineLatest([this._stateService.selectedProcessLink$, this.hasResultMappingsStep$]) + .pipe( + // The outputs resolution is async (definition + manifest lookups) and its first emission + // can be a premature empty result — taking a single emission would lock in the 3-step + // layout whenever those lookups lose the race. Wait for the first conclusive + // "has outputs" instead; links whose action declares none simply never switch. + filter( + ([selectedProcessLink, hasResultMappingsStep]) => + selectedProcessLink?.processLinkType === 'external_plugin' && hasResultMappingsStep + ), + take(1) + ) + .subscribe(() => { + this._stepService.initializeEditModeResultMappingsSteps(); + }) + ); + } + + private resolveExternalActionBundleUrl(): void { + this._subscriptions.add( + combineLatest([ + this._pluginStateService.selectedPluginDefinition$, + this._pluginStateService.selectedPluginFunction$, + this._stateService.selectedProcessLink$, + ]) + .pipe( + switchMap(([definition, selectedFunction, selectedProcessLink]) => { + if (!definition?.key || !isExternalPluginKey(definition.key)) { + return of(null); + } + + const actionKey = selectedFunction?.key || selectedProcessLink?.actionKey || null; + + const definitionId = extractExternalDefinitionId(definition.key); + return this._externalPluginService.getDefinition(definitionId).pipe( + map((extDef: ExternalPluginDefinition) => ({extDef, actionKey})), + // A failed bundle resolve must not break the flow: emit null so the subscriber + // marks the lookup as resolved without a bundle URL, which makes the template fall + // back to the raw-JSON textarea configuration mode. + catchError(() => of(null)) + ); + }) + ) + .subscribe(result => { + if (!result) { + this.externalActionBundleUrl$.next(null); + return; + } + + const {extDef, actionKey} = result; + const actionBundle = extDef.manifest?.frontendBundles?.find( + b => b.type === 'process-link-action' && (!b.key || b.key === actionKey) + ); + + if (actionBundle) { + const bundleUrl = `${extDef.baseUrl}/${extDef.version}${actionBundle.path}`; + this.externalActionBundleUrl$.next(bundleUrl); + + // Set up prefill for editing existing process links + if ( + this.externalActionProperties && + Object.keys(this.externalActionProperties).length > 0 + ) { + this.externalActionPrefill$.next({ + title: '', + configuration: this.externalActionProperties, + }); + } + } else { + this.externalActionBundleUrl$.next(null); + } + }) + ); + } + private updateProcessLink(configuration: PluginConfigurationData): void { combineLatest([ - this.stateService.selectedProcessLink$, - this.pluginStateService.selectedPluginFunction$, + this._stateService.selectedProcessLink$, + this._pluginStateService.selectedPluginFunction$, ]) .pipe(take(1)) .subscribe(([selectedProcessLink, selectedFunction]) => { @@ -133,7 +449,8 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { ? (selectedProcessLink.pluginConfigurationId ?? '') : undefined; // Use the currently selected function key (user may have changed it) - const actionKey = selectedFunction?.key ?? selectedProcessLink.pluginActionDefinitionKey ?? ''; + const actionKey = + selectedFunction?.key ?? selectedProcessLink.pluginActionDefinitionKey ?? ''; const updateProcessLinkRequest: PluginProcessLinkUpdateDto = { id: selectedProcessLink.id, pluginConfigurationId, @@ -142,19 +459,20 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { activityId: selectedProcessLink.activityId, referenceType: inferredReferenceType, pluginDefinitionKey: selectedProcessLink.pluginDefinitionKey, + actionResultMappings: this.actionResultMappings, }; - this.stateService.sendProcessLinkUpdateEvent(updateProcessLinkRequest); + this._stateService.sendProcessLinkUpdateEvent(updateProcessLinkRequest); }); } private saveNewProcessLink(configuration: PluginConfigurationData): void { combineLatest([ - this.stateService.modalParams$, - this.pluginStateService.selectedPluginConfiguration$, - this.pluginStateService.selectedPluginFunction$, - this.stateService.selectedProcessLinkTypeId$, - this.pluginStateService.selectedPluginDefinition$, + this._stateService.modalParams$, + this._pluginStateService.selectedPluginConfiguration$, + this._pluginStateService.selectedPluginFunction$, + this._stateService.selectedProcessLinkTypeId$, + this._pluginStateService.selectedPluginDefinition$, ]) .pipe(take(1)) .subscribe( @@ -165,17 +483,17 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { selectedProcessLinkTypeId, selectedDefinition, ]) => { - const isBuildingBlock = this.stateService.isBuildingBlockContext(); + const isBuildingBlock = this._stateService.isBuildingBlockContext(); const pluginDefinitionKey = selectedConfiguration?.pluginDefinition?.key || selectedDefinition?.key; if (!selectedFunction || (isBuildingBlock && !pluginDefinitionKey)) { - this.stateService.stopSaving(); + this._stateService.stopSaving(); return; } if (!isBuildingBlock && !selectedConfiguration) { - this.stateService.stopSaving(); + this._stateService.stopSaving(); return; } @@ -193,31 +511,225 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { processLinkType: selectedProcessLinkTypeId, referenceType, pluginDefinitionKey, + actionResultMappings: this.actionResultMappings, }; - this.stateService.sendProcessLinkCreateEvent(processLinkRequest); + this._stateService.sendProcessLinkCreateEvent(processLinkRequest); } ); } private openBackButtonSubscription(): void { this._subscriptions.add( - this.buttonService.backButtonClick$ + this._buttonService.backButtonClick$ + .pipe( + withLatestFrom(this._stateService.isEditing$, this.currentStepId$), + filter( + ([, isEditing, currentStepId]) => + !isEditing && currentStepId === 'configurePluginAction' + ) + ) + .subscribe(() => { + this._stepService.setChoosePluginActionSteps(); + }) + ); + } + + /** + * Distinct from {@link openBackButtonSubscription}: back from the output-mapping step returns to + * the (still-alive) properties step instead of `choosePluginAction`, guarded on this component's + * own step relevance the same way the other back-handlers guard on `!isEditing`. + */ + private openMappingsBackButtonSubscription(): void { + this._subscriptions.add( + this._buttonService.backButtonClick$ + .pipe( + withLatestFrom(this._stateService.isEditing$, this.currentStepId$), + filter( + ([, isEditing, currentStepId]) => + !isEditing && currentStepId === 'configurePluginActionResultMappings' + ) + ) + .subscribe(() => { + this._stepService.setConfigurePluginActionSteps(); + }) + ); + } + + private openNextButtonSubscription(): void { + this._subscriptions.add( + this._buttonService.nextButtonClick$ .pipe( - withLatestFrom(this.stateService.isEditing$), - filter(([, isEditing]) => !isEditing) + withLatestFrom( + this._stateService.isEditing$, + this.currentStepId$, + this.hasResultMappingsStep$ + ), + filter( + ([, isEditing, currentStepId, hasResultMappingsStep]) => + !isEditing && currentStepId === 'configurePluginAction' && hasResultMappingsStep + ) ) .subscribe(() => { - this.stepService.setChoosePluginActionSteps(); + this._stepService.setConfigurePluginActionResultMappingsSteps(); }) ); } private openSaveButtonSubscription(): void { this._subscriptions.add( - this.buttonService.saveButtonClick$.subscribe(() => { - this.pluginStateService.save(); - }) + this._buttonService.saveButtonClick$ + .pipe(withLatestFrom(this.isExternalPlugin$)) + .subscribe(([, isExternal]) => { + if (isExternal) { + this.saveExternalPluginProcessLink(); + } else { + this._pluginStateService.save(); + } + }) ); } + + private saveExternalPluginProcessLink(): void { + this._stateService.startSaving(); + + const isBuildingBlock = this._stateService.isBuildingBlockContext(); + + combineLatest([ + this._stateService.modalParams$, + this._pluginStateService.selectedPluginConfiguration$, + this._pluginStateService.selectedPluginFunction$, + this._stateService.selectedProcessLink$, + this._pluginStateService.selectedPluginDefinition$, + ]) + .pipe( + take(1), + switchMap( + ([ + modalData, + selectedConfiguration, + selectedFunction, + selectedProcessLink, + selectedDefinition, + ]) => { + if (!selectedFunction || !selectedDefinition) { + this._stateService.stopSaving(); + return of(null); + } + if (!isBuildingBlock && !selectedConfiguration) { + this._stateService.stopSaving(); + return of(null); + } + + const definitionId = extractExternalDefinitionId(selectedDefinition.key); + return this._externalPluginService.getDefinition(definitionId).pipe( + map(definition => ({ + modalData, + selectedConfiguration, + selectedFunction, + selectedProcessLink, + pluginVersion: definition.version, + pluginDefinitionKey: definition.pluginId, + })), + // A failed definition lookup would otherwise leave the modal in the "saving" state + // forever — release it and bail out (the global HTTP interceptor shows the error). + catchError(() => { + this._stateService.stopSaving(); + return of(null); + }) + ); + } + ) + ) + .subscribe(result => { + if (!result) return; + + const { + modalData, + selectedConfiguration, + selectedFunction, + selectedProcessLink, + pluginVersion, + pluginDefinitionKey, + } = result; + + // A user-task link is the plugin's task-form (rendered + completed by the plugin), not a + // service-task action — persist it as the dedicated `external_plugin_task_form` type. + const isTaskForm = selectedProcessLink + ? selectedProcessLink.processLinkType === 'external_plugin_task_form' + : modalData?.element?.activityListenerType === USER_TASK_ACTIVITY; + + if (isTaskForm) { + // A task-form link always references a concrete configuration — without one (or its id) + // there is nothing valid to persist, so release the saving state and bail out. + if (!selectedConfiguration?.id) { + this._stateService.stopSaving(); + return; + } + + // The selected "function" is a task-form bundle; its key is the bundle key (empty = the + // plugin's sole task-form bundle → null). + const bundleKey = selectedFunction.key || null; + if (selectedProcessLink) { + const updateRequest: ExternalPluginTaskFormProcessLinkUpdateDto = { + id: selectedProcessLink.id, + processLinkType: 'external_plugin_task_form', + externalPluginConfigurationId: selectedConfiguration.id, + pluginVersion, + bundleKey, + }; + this._stateService.sendProcessLinkUpdateEvent(updateRequest); + } else { + const createRequest: ExternalPluginTaskFormProcessLinkCreateDto = { + processDefinitionId: modalData?.processDefinitionId, + activityId: modalData?.element?.id, + activityType: modalData?.element?.activityListenerType ?? '', + processLinkType: 'external_plugin_task_form', + externalPluginConfigurationId: selectedConfiguration.id, + pluginVersion, + bundleKey, + }; + this._stateService.sendProcessLinkCreateEvent(createRequest); + } + return; + } + + const actionProperties = this.externalActionProperties; + const actionResultMappings = this.actionResultMappings; + + // In building-block context we send referenceType 'BUILDING_BLOCK' + pluginDefinitionKey + + // pluginVersion and omit externalPluginConfigurationId — the concrete configuration is + // resolved at runtime from the building block's plugin mappings (D1/D2), mirroring the + // embedded save path's building-block branch. + if (selectedProcessLink) { + const updateRequest: ExternalPluginProcessLinkUpdateDto = { + id: selectedProcessLink.id, + processLinkType: 'external_plugin', + externalPluginConfigurationId: isBuildingBlock ? undefined : selectedConfiguration.id, + actionKey: selectedFunction.key, + pluginVersion, + referenceType: isBuildingBlock ? 'BUILDING_BLOCK' : 'FIXED', + pluginDefinitionKey: isBuildingBlock ? pluginDefinitionKey : undefined, + actionProperties, + actionResultMappings, + }; + this._stateService.sendProcessLinkUpdateEvent(updateRequest); + } else { + const createRequest: ExternalPluginProcessLinkCreateDto = { + processDefinitionId: modalData?.processDefinitionId, + activityId: modalData?.element?.id, + activityType: modalData?.element?.activityListenerType ?? '', + processLinkType: 'external_plugin', + externalPluginConfigurationId: isBuildingBlock ? undefined : selectedConfiguration.id, + actionKey: selectedFunction.key, + pluginVersion, + referenceType: isBuildingBlock ? 'BUILDING_BLOCK' : 'FIXED', + pluginDefinitionKey: isBuildingBlock ? pluginDefinitionKey : undefined, + actionProperties, + actionResultMappings, + }; + this._stateService.sendProcessLinkCreateEvent(createRequest); + } + }); + } } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html new file mode 100644 index 0000000000..602ff773bb --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html @@ -0,0 +1,113 @@ + + +
+

+ {{ 'processLinkConfiguration.resultMappings.description' | translate }} +

+ +
+ @if (mappingRows.controls.length > 0) { +
+ + + +
+ } + +
+ @for (group of mappingRows.controls; let rowIndex = $index; track rowIndex) { +
+
+
+ @if (hasDeclaredSourceKeys) { + + + } @else { + + } +
+ +
+
+ +
+
+ @if (hasDocumentContext) { + + } @else { + + } +
+ +
+ +
+
+
+ } +
+
+ + +
diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss new file mode 100644 index 0000000000..5a15c7b023 --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss @@ -0,0 +1,69 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.result-mappings-section { + display: flex; + flex-direction: column; + + .section-description { + color: var(--cds-text-secondary); + margin-bottom: var(--cds-spacing-04); + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + } + + .mapping-header, + .mapping-row { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + } + + .mapping-row { + margin-bottom: var(--cds-spacing-03); + padding: var(--cds-spacing-03); + background-color: var(--cds-layer); + + .mapping-part { + display: flex; + } + + .arrow { + text-align: center; + width: var(--cds-spacing-08); + line-height: var(--cds-spacing-08); + font-size: var(--cds-body-02-font-size, 1rem); + } + + .mapping-cell { + flex: 1; + min-width: 0; + } + + .mapping-cell-actions { + display: flex; + align-self: flex-end; + margin-left: var(--cds-spacing-03); + + button { + margin-bottom: var(--cds-spacing-02); + + ::ng-deep .cds--btn__icon { + margin: 0 !important; + } + } + } + } +} diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts new file mode 100644 index 0000000000..4a151f725c --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts @@ -0,0 +1,253 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import { + FormArray, + FormBuilder, + FormControl, + ReactiveFormsModule, +} from '@angular/forms'; +import {Subscription} from 'rxjs'; +import {isEqual} from 'lodash'; +import { + ButtonModule, + IconModule, + InputModule, + LayerModule, +} from 'carbon-components-angular'; +import {TranslateModule} from '@ngx-translate/core'; +import { + InputLabelModule, + SelectItem, + SelectModule, + ValuePathSelectorComponent, + ValuePathSelectorPrefix, +} from '@valtimo/components'; +import {PluginActionResultMapping} from '../../models'; +import {ResultMappingRowFormGroup, ResultMappingsFormGroup} from '../../models'; +import {PLUGIN_ACTION_RESULT_MAPPINGS_TEST_IDS} from '../../constants'; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-action-result-mappings', + templateUrl: './plugin-action-result-mappings.component.html', + styleUrls: ['./plugin-action-result-mappings.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + InputModule, + ValuePathSelectorComponent, + InputLabelModule, + TranslateModule, + ButtonModule, + IconModule, + LayerModule, + SelectModule, + ], +}) +export class PluginActionResultMappingsComponent implements OnInit, OnDestroy { + protected readonly testIds = PLUGIN_ACTION_RESULT_MAPPINGS_TEST_IDS; + protected readonly ValuePathSelectorPrefix = ValuePathSelectorPrefix; + + private readonly formBuilder = inject(FormBuilder); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + + @Input() public set mappings(value: Array | null | undefined) { + const incoming = value ?? []; + if (isEqual(incoming, this.currentMappings())) { + return; + } + this.rebuildForm(incoming); + this.scheduleControlRefresh(); + } + + @Input() public caseDefinitionKey: string | null | undefined; + @Input() public caseDefinitionVersionTag: string | null | undefined; + @Input() public buildingBlockDefinitionKey: string | null | undefined; + @Input() public buildingBlockDefinitionVersionTag: string | null | undefined; + + /** + * Keys the selected external action declares in its manifest `outputs`. When non-empty, the + * source column renders as a dropdown restricted to these keys instead of a free-text JSON + * pointer — the persisted value is still an RFC 6901 pointer (`/` + key) so the backend handler + * stays untouched. + */ + @Input() public set sourceKeys(value: Array | null | undefined) { + this._sourceKeys = value ?? []; + this._sourceKeyItems = this._sourceKeys.map(key => ({id: key, text: key})); + if (this.hasDeclaredSourceKeys) { + // Keys can arrive after the rows (manifest lookup): rows created in free-text mode still + // hold pointer-shaped sources ('/key') that the dropdown items ('key') would not match. + this.mappingRows.controls.forEach(group => { + const source = group.controls.source.value ?? ''; + if (source.startsWith('/')) { + group.controls.source.setValue(source.replace(/^\//, ''), {emitEvent: false}); + } + }); + } + this.scheduleControlRefresh(); + } + + public get sourceKeys(): Array { + return this._sourceKeys; + } + + public get hasDeclaredSourceKeys(): boolean { + return this._sourceKeys.length > 0; + } + + public get sourceKeyItems(): Array { + return this._sourceKeyItems; + } + + private _sourceKeys: Array = []; + private _sourceKeyItems: Array = []; + + @Output() public mappingsChangeEvent = new EventEmitter>(); + + /** + * Emits whether the current rows are saveable: no rows at all is valid, but every present row + * must have both a source and a target. + */ + @Output() public validityChangeEvent = new EventEmitter(); + + public readonly mappingsForm: ResultMappingsFormGroup = this.formBuilder.group({ + mappings: this.formBuilder.array([]), + }); + + public get mappingRows(): FormArray { + return this.mappingsForm.controls.mappings; + } + + /** + * Without a case or building-block context there is no document schema to browse, so the + * target falls back to a free-text value-resolver key (typically `pv:`) — the same degradation + * the building-block mapping step applies for independent processes. + */ + public get hasDocumentContext(): boolean { + return ( + !!(this.caseDefinitionKey && this.caseDefinitionVersionTag) || + !!(this.buildingBlockDefinitionKey && this.buildingBlockDefinitionVersionTag) + ); + } + + private _subscriptions = new Subscription(); + private _refreshHandle: number | null = null; + private _destroyed = false; + + public ngOnInit(): void { + this._subscriptions.add( + this.mappingsForm.valueChanges.subscribe(() => { + this.mappingsChangeEvent.emit(this.currentMappings()); + this.validityChangeEvent.emit(this.isValid()); + }) + ); + this.validityChangeEvent.emit(this.isValid()); + } + + public ngOnDestroy(): void { + this._destroyed = true; + if (this._refreshHandle !== null) { + clearTimeout(this._refreshHandle); + } + this._subscriptions.unsubscribe(); + } + + public addRow(): void { + this.mappingRows.push(this.createRow()); + this.validityChangeEvent.emit(this.isValid()); + this.changeDetectorRef.detectChanges(); + } + + public deleteRow(index: number): void { + this.mappingRows.removeAt(index); + this.validityChangeEvent.emit(this.isValid()); + this.changeDetectorRef.detectChanges(); + } + + /** + * Deferred re-sync after rows or dropdown items change outside a user event (prefill, late + * manifest lookup): re-setting each control value once the current change-detection pass has + * finished makes the select components pick up selections they missed while initializing, and + * the explicit detectChanges renders it under OnPush. Mirrors the refresh + * `ConfigureBuildingBlockMappingsComponent` applies to its own rows. + */ + private scheduleControlRefresh(): void { + if (this._refreshHandle !== null) { + clearTimeout(this._refreshHandle); + } + this._refreshHandle = window.setTimeout(() => { + this._refreshHandle = null; + if (this._destroyed) return; + this.mappingRows.controls.forEach(group => { + group.controls.source.setValue(group.controls.source.value ?? '', {emitEvent: false}); + group.controls.target.setValue(group.controls.target.value ?? '', {emitEvent: false}); + }); + this.changeDetectorRef.detectChanges(); + }, 0); + } + + private createRow(mapping?: PluginActionResultMapping): ResultMappingRowFormGroup { + const source = mapping?.source ?? ''; + return this.formBuilder.group({ + source: new FormControl( + this.hasDeclaredSourceKeys ? source.replace(/^\//, '') : source, + {nonNullable: true} + ), + target: new FormControl(mapping?.target ?? '', {nonNullable: true}), + }); + } + + private rebuildForm(mappings: Array): void { + this.mappingRows.clear({emitEvent: false}); + mappings.forEach(mapping => + this.mappingRows.push(this.createRow(mapping), {emitEvent: false}) + ); + } + + private currentMappings(): Array { + // Incomplete rows are emitted as-is (not silently dropped) — the validity event keeps the + // save button disabled until every row carries both values. + return this.mappingRows.controls.map(group => { + const rawSource = group.controls.source.value?.trim() ?? ''; + const source = + this.hasDeclaredSourceKeys && rawSource && !rawSource.startsWith('/') + ? `/${rawSource}` + : rawSource; + return { + source, + target: group.controls.target.value?.trim() ?? '', + }; + }); + } + + private isValid(): boolean { + return this.currentMappings().every(mapping => !!mapping.source && !!mapping.target); + } +} diff --git a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html index fb4096fcd1..76d4419383 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html @@ -1,5 +1,5 @@ + +@if ($state() === 'loading') { +
+ +
+} @else if ($state() === 'error') { + +} @else { + +} diff --git a/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.scss b/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.scss new file mode 100644 index 0000000000..ac29a4ea54 --- /dev/null +++ b/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.scss @@ -0,0 +1,22 @@ +/*! + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.task-external-plugin-form__loading { + width: 100%; + display: flex; + justify-content: center; + padding: var(--cds-spacing-05); +} diff --git a/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.ts b/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.ts new file mode 100644 index 0000000000..42d2b3bc75 --- /dev/null +++ b/frontend/projects/valtimo/task/src/lib/components/task-external-plugin-form/task-external-plugin-form.component.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpErrorResponse} from '@angular/common/http'; +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + signal, + ViewChild, +} from '@angular/core'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import { + derivePluginDataUrl, + ExternalPluginIframeComponent, + ExternalPluginSessionService, + ExternalPluginTaskFormSubmissionResult, + ExternalPluginTaskFormSubmissionService, +} from '@valtimo/plugin'; +import {LoadingModule, NotificationModule} from 'carbon-components-angular'; +import {Subscription} from 'rxjs'; + +type FormState = 'loading' | 'ready' | 'error'; + +/** + * Renders an external plugin's `task-form` bundle for a user task and owns the submission on the + * plugin's behalf. + * + * The default path (Level 0/1): the plugin bundle calls `sdk.submitTask(data)`, which surfaces here + * as `submitTaskEvent`; this component POSTs the data to GZAC's task-form submission endpoint (as the + * logged-in user) and GZAC completes the task the standard way. On success it emits `completedEvent` + * so the modal closes and the list refreshes; on validation failure it replies to the iframe so the + * plugin can render the errors without being torn down. + * + * The escape hatch (Level 2): a plugin may still complete the task itself (via `request()` + + * `gzacApi.asUser`) and emit `taskCompleted`, which arrives as `taskCompletedEvent` → `completedEvent`. + * For that path the downscoped user token is minted below; for Level 0/1 the token is not required + * (minting is best-effort so a pure form still works if it cannot be minted). + */ +@Component({ + selector: 'valtimo-task-external-plugin-form', + templateUrl: './task-external-plugin-form.component.html', + styleUrls: ['./task-external-plugin-form.component.scss'], + standalone: true, + providers: [ExternalPluginSessionService], + imports: [ + CommonModule, + LoadingModule, + NotificationModule, + TranslateModule, + ExternalPluginIframeComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TaskExternalPluginFormComponent implements OnInit, OnDestroy { + @Input({required: true}) public bundleUrl!: string; + @Input({required: true}) public configurationId!: string; + @Input({required: true}) public processLinkId!: string; + @Input() public context: Record = {}; + + @Output() public readonly completedEvent = new EventEmitter(); + + @ViewChild(ExternalPluginIframeComponent) + private readonly _iframe?: ExternalPluginIframeComponent; + + public readonly $state = signal('loading'); + public readonly $pluginDataUrl = signal(null); + + private readonly _subscriptions = new Subscription(); + + constructor( + protected readonly sessionService: ExternalPluginSessionService, + private readonly submissionService: ExternalPluginTaskFormSubmissionService, + private readonly translateService: TranslateService + ) {} + + public ngOnInit(): void { + // The session service owns minting, the pre-expiry re-mint and retry-with-backoff on failure. + this.sessionService.startSession(this.configurationId).subscribe({ + next: () => this._onReady(), + // A token is only needed for the Level 2 escape hatch (and live GZAC reads while editing); + // Level 0/1 submission goes through GZAC directly. Render anyway so a pure form still works. + error: () => this._onReady(), + }); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + /** Level 2: the plugin completed the task itself; just finalise the UI. */ + public onTaskCompleted(): void { + this.completedEvent.emit(); + } + + /** Level 0/1: the plugin handed data up; submit it to GZAC, which completes the task. */ + public onSubmitTask(event: {correlationId: string; data: Record}): void { + const documentId = (this.context['documentId'] as string) ?? null; + const taskInstanceId = (this.context['taskId'] as string) ?? null; + + this._subscriptions.add( + this.submissionService + .submit(this.processLinkId, event.data, documentId, taskInstanceId) + .subscribe({ + next: () => { + this._iframe?.sendSubmitResult({correlationId: event.correlationId, ok: true}); + this.completedEvent.emit(); + }, + error: (error: HttpErrorResponse) => this._onSubmitError(event.correlationId, error), + }) + ); + } + + private _onSubmitError(correlationId: string, error: HttpErrorResponse): void { + // A 400 carries the structured submission result (validation / plugin-hook rejection); any other + // status is an unexpected failure surfaced with a generic message. + const body = error?.error as ExternalPluginTaskFormSubmissionResult | undefined; + const fieldErrors = body?.fieldErrors ?? {}; + const errors = + body?.errors && body.errors.length > 0 + ? body.errors + : [this.translateService.instant('taskDetail.externalPluginTaskForm.submitError')]; + this._iframe?.sendSubmitResult({correlationId, ok: false, errors, fieldErrors}); + } + + private _onReady(): void { + this.$pluginDataUrl.set(derivePluginDataUrl(this.bundleUrl)); + this.$state.set('ready'); + } +} diff --git a/plugin-host/.gitignore b/plugin-host/.gitignore new file mode 100644 index 0000000000..5dbcea3794 --- /dev/null +++ b/plugin-host/.gitignore @@ -0,0 +1,2 @@ +# Local binary tooling (extism-js) +.bin/ diff --git a/plugin-host/README.md b/plugin-host/README.md new file mode 100644 index 0000000000..80cf44595c --- /dev/null +++ b/plugin-host/README.md @@ -0,0 +1,123 @@ +# Valtimo External Plugin System + +WebAssembly-based plugin system for extending Valtimo GZAC with custom actions and event handlers. + +## Components + +| Directory | Description | +|-----------|-------------| +| [`app/`](./app/) | **Plugin Host** — Node.js sidecar that loads, stores, and executes Wasm plugins | +| [`plugin-sdk/`](./plugin-sdk/) | **SDK** — TypeScript library and build tools for plugin authors | +| [`sample-plugins/`](./sample-plugins/) | **Sample plugins** — Reference implementations | + +> **Testing:** see [`TESTING.md`](./TESTING.md) for the test layers (unit, component, Wasm, +> integration, contract), how to run them, and **which kind of test to write when** you change code. + +## Architecture + +``` +┌─────────────┐ push configs ┌─────────────────┐ +│ GZAC │ ───────────────────────▶│ Plugin Host │ +│ (backend) │ │ (Node.js app) │ +│ │◀─── action results ─────│ │ +│ │ │ ┌───────────┐ │ +│ │ │ │ Extism │ │ +│ │──── call action ───────▶│ │ ┌─────┐ │ │ +│ │ │ │ │Wasm │ │ │ +│ │ │ │ │Plug │ │ │ +│ │ gzac_api callback │ │ │ in │ │ │ +│ │◀────────────────────────│ │ └─────┘ │ │ +│ │ │ └───────────┘ │ +└─────────────┘ └────────┬────────┘ + │ + │ persists + ▼ + ┌─────────────────┐ + │ PostgreSQL │ + │ (configs, etc) │ + └─────────────────┘ +``` + +## Quick Start + +### 1. Start the Plugin Host + +```bash +cd app +npm install +npm run db:up # Start PostgreSQL +npm run dev # Start host with auto-reload +``` + +### 2. Build a Sample Plugin + +```bash +cd plugin-sdk +npm install && npm run build + +cd ../sample-plugins/case-summary +npm install +npm run build:pack +``` + +### 3. Upload and Test + +Every GZAC→host request is HMAC-SHA256 signed (not a bearer token): the signature covers +`{METHOD}\n{path}\n{timestamp}\n{bodyHash}` keyed with the `ADMIN_TOKEN`, sent as `X-Valtimo-Signature` ++ `X-Valtimo-Timestamp`. Replay protection is two-layered: the timestamp must be within ±5 minutes +of the host's clock, and on side-effecting routes (POST/PUT/DELETE) each accepted signature is +single-use within that window — resending a captured request verbatim is refused with 401. The +plugin upload signs the file bytes; other write routes sign the request body. HMAC authenticates and integrity-binds each request but does not +encrypt it — run the host over TLS (set `TLS_CERT_PATH`/`TLS_KEY_PATH`) so the config push, which +carries broker credentials and the service token, is also confidential. See +[`app/README.md`](app/README.md#api-reference) for the full scheme and the `host_sign` helper used +below, and [Transport security](app/README.md#transport-security) for TLS. + +```bash +ADMIN_TOKEN=test-secret +# host_sign METHOD PATH [BODY_FILE] → sets $TS and $SIG +host_sign() { + TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + local hash; hash="$(openssl dgst -sha256 -hex "${3:-/dev/null}" | awk '{print $NF}')" + SIG="$(printf '%s\n%s\n%s\n%s' "$1" "$2" "$TS" "$hash" \ + | openssl dgst -sha256 -hmac "$ADMIN_TOKEN" -hex | awk '{print $NF}')" +} + +# Upload plugin (signature binds the .zip file bytes) +host_sign POST /api/host/plugins sample-plugins/case-summary/dist/case-summary-0.1.0.zip +curl -X POST http://localhost:8090/api/host/plugins \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -F "file=@sample-plugins/case-summary/dist/case-summary-0.1.0.zip" + +# Push a configuration (signature binds the JSON body) +printf '%s' '{"pluginId":"case-summary","pluginVersion":"0.1.0","properties":{"titleField":"/name"},"serviceToken":"test","gzacBaseUrl":"http://localhost:8080"}' > /tmp/cfg.json +host_sign POST /api/host/configurations/test-cfg /tmp/cfg.json +curl -X POST http://localhost:8090/api/host/configurations/test-cfg \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/cfg.json + +# Execute an action (signature binds the JSON body) +printf '%s' '{"configurationId":"test-cfg","processInstanceId":"p1","documentId":"d1","activityId":"a1","properties":{}}' > /tmp/action.json +host_sign POST /plugins/case-summary/0.1.0/actions/case-summary /tmp/action.json +curl -X POST http://localhost:8090/plugins/case-summary/0.1.0/actions/case-summary \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/action.json +``` + +## Docker Deployment + +```bash +cd app +npm run build +ADMIN_TOKEN=your-secret npm run docker:up +``` + +This starts both PostgreSQL and the Plugin Host. Plugin binaries persist to a Docker volume. + +## Documentation + +- [Plugin Host README](./app/README.md) — API reference, configuration, events +- [Plugin SDK README](./plugin-sdk/README.md) — Building plugins, SDK API +- [Case Summary Plugin](./sample-plugins/case-summary/README.md) — Example with GZAC callbacks diff --git a/plugin-host/TESTING.md b/plugin-host/TESTING.md new file mode 100644 index 0000000000..3198ede128 --- /dev/null +++ b/plugin-host/TESTING.md @@ -0,0 +1,277 @@ + + +# Testing the Plugin Host & SDK + +This guide explains how the two TypeScript packages here are tested: + +- **`plugin-host/app`** — the *host*: the small Node.js service that stores plugins and runs them. +- **`plugin-host/plugin-sdk`** — the *SDK*: the library and build tools plugin authors use. + +More important than *how* we test is **what kind of test to write when you change something**. The +idea is simple: pick the lightest kind of test that can still catch the mistake you might make. A +plain function is cheap to test on its own; a database query is not, so only reach for the heavy, +slow tests when a light one genuinely can't cover the risk. + +> This file is the canonical guide to how the plugin host & SDK are tested. + +## A quick tour of the five kinds of test + +We group tests into five layers, from lightest to heaviest. "Lighter" means faster and with fewer +things that need to be installed or running. + +| Layer | In one sentence | Needs | +|-------|-----------------|-------| +| **L1 — Unit** | Call one function on its own and check what it returns. | nothing extra | +| **L2 — Component** | Send a fake HTTP request into a route and check the response. | nothing extra | +| **L3 — Wasm** | Build a real plugin and actually run it in the sandbox. | Node ≥ 22, the `extism-js` compiler | +| **L4 — Integration** | Run the code against a real database and message broker. | Docker | +| **L5 — Contract** | Prove our code agrees with the Java/Kotlin backend, byte for byte. | nothing extra | + +All tests use **Vitest** (a test runner, like Jest). The everyday command is `npm test`, which runs +only the fast layers (L1, L2, L5) — so it needs nothing installed beyond the npm packages and runs +anywhere. The two heavy layers (L3, L4) are separate commands you run on purpose, and they each get +their own job in CI. + +```bash +# plugin-sdk/ +npm test # L1 (+ the browser-side SDK, see "happy-dom" below) + +# app/ +npm test # L1 + L2 + L5 — fast, no Docker, no extra tools +npm run test:cov # the same, plus a coverage report +npm run test:wasm # L3 — needs Node 22 and the extism-js compiler +npm run test:int # L4 — needs Docker running +``` + +## What you need installed + +- **Node.js** — `npm test` runs on any supported version. **L3 (`npm run test:wasm`) needs Node 22 + or newer.** That is because running a plugin uses a background worker thread that older Node + versions can't start. (The part of L3 that needs Node 22 skips itself automatically on older + versions, so nothing breaks — those tests just don't run.) +- **`extism-js`** — only for L3. This is the compiler that turns a plugin's TypeScript into a + WebAssembly (`.wasm`) file. We don't commit it to the repo (it's a large binary). Download it from + https://github.com/extism/js-pdk/releases and drop it at `plugin-host/.bin/extism-js`; the test + setup looks for it there. On macOS the OS blocks freshly-downloaded binaries once — clear that with + `xattr -d com.apple.quarantine plugin-host/.bin/extism-js`. CI downloads it automatically. +- **Docker** — only for L4. The tests start throwaway Postgres and RabbitMQ containers themselves and + shut them down afterwards, so you just need Docker running; no manual setup. + +## The layers in detail + +### L1 — Unit: test one function on its own + +The default and by far the most common. You import a single function, call it with some input, and +check the output. Anything it would normally talk to (the network, a database, the file system) is +replaced with a **stub** — a stand-in fake you control, so the test stays fast and predictable. + +Example — the signing function is called directly, no server involved: + +```ts +expect(verifyHmac(secret, "POST", path, signature, timestamp, body).valid).toBe(true); +``` + +Where you'll find these: `security/hmac.test.ts`, `host-functions/gzac-api.test.ts` (with a fake +plugin call-context and a stubbed `fetch`), `rabbitmq/event-consumer.test.ts` (with a fake message +library), `models/app-config.test.ts`, `https-options.test.ts`, and in the SDK +`manifest-validation.test.ts` and `frontend/plugin-frontend-sdk.test.ts`. + +> **"happy-dom".** The browser-side SDK code expects browser globals like `window`. Node doesn't have +> those, so that one test file runs in **happy-dom**, a lightweight fake browser. It's switched on per +> file with a one-line comment at the top (`// @vitest-environment happy-dom`). + +### L2 — Component: send a fake request into a route + +L1 and L2 look similar (same runner, same folder, both fast) — the difference is *what* they test. +L1 checks a single function. **L2 checks a whole HTTP route the way a real caller would hit it**, but +without opening a real network port. Vitest's `inject()` feeds a made-up request through the *actual* +web framework (Fastify) — routing, authentication checks, body parsing, the handler, the response — +and hands back the status code and body to check. + +This catches wiring mistakes a unit test can't: a wrong status code, an authentication check that +was forgotten on a route, a missing CORS header, and so on. + +```ts +import { buildTestApp, signHeaders, testConfig } from "../test-support/harness"; + +const app = await buildTestApp((a) => hostConfigurationRoutes(a, { /* fake dependencies */ })); +const res = await app.inject({ + method: "POST", + url: "/api/host/configurations/cfg-1", + headers: { "content-type": "application/json", ...signHeaders("POST", path, payload) }, + payload, +}); +expect(res.statusCode).toBe(201); +``` + +- `buildTestApp` sets up the web app exactly like production (same request-body handling, same file + upload handling), then lets you register just the route you're testing. +- `signHeaders` produces a valid request signature (see **HMAC** below) using a *different* + implementation than the one being tested — so the test proves the route really accepts a properly + signed request, not just that the code agrees with itself. +- The route's helpers (plugin manager, config store, etc.) are still fakes; only the web framework is + real. + +> **HMAC, in plain terms.** Every request from the backend to the host carries a signature. The +> signature is made by mixing the request (method, path, timestamp, body) with a shared secret both +> sides know. The host recomputes it and compares. If they match, the request genuinely came from the +> backend and wasn't altered in transit. If the body is changed or the secret is wrong, the signature +> won't match and the host rejects it (HTTP 401). + +### L3 — Wasm: build a real plugin and run it + +This is the only layer that compiles a real plugin and executes it. "Wasm" is **WebAssembly** — the +sandboxed format plugins are compiled to; **Extism** is the runtime that loads and runs them safely. + +We need this layer because two things simply cannot be reproduced by faking them in Node — they only +happen for real inside the WebAssembly sandbox: + +- How the SDK settles a plugin's `async`/`await` code (the sandbox uses a tiny JavaScript engine, + QuickJS, that behaves differently from Node here — see the note at the end of this file). +- How the host safely runs one plugin call at a time (the sandbox refuses to be called twice at once, + and the host has a lock to prevent that). + +The tests use a small, purpose-built **fixture plugin** at `test-fixtures/test-plugin/`. (A *fixture* +is a fixed, reusable piece of test setup — here, a tiny real plugin with predictable handlers like +`echo`, `boom`, and an event handler.) A setup step compiles it to `.wasm` automatically before the +tests run, so to add a case you just add a handler to the fixture. + +```bash +# from plugin-host/app, with Node 22 active and extism-js in ../.bin +npm run test:wasm +``` + +### L4 — Integration: test against a real database and broker + +Here we run the real code against a **real Postgres database and a real RabbitMQ message broker** — +not fakes. Starting real services is slow, so we only use this for things where the fake wouldn't be +trustworthy: the actual SQL and JSON storage, and the live behaviour of message delivery (including +recovering after the broker connection drops). + +This is powered by **Testcontainers**, a library that starts a throwaway service in a Docker +container just for the test and removes it afterwards: + +```ts +const pg = await new PostgreSqlContainer("postgres:16-alpine").start(); +const rabbit = await new RabbitMQContainer("rabbitmq:3.13-management-alpine").start(); +``` + +Keep each test independent (its own message exchange, a cleared table between tests) and wait for +things to arrive with a poll-until-true helper rather than a fixed sleep, so the tests aren't flaky. + +### L5 — Contract: prove we match the backend + +Some of our code has to agree *exactly* with the Java/Kotlin backend — for example, both sides must +compute the same request signature, or a plugin accepted by one side must be accepted by the other. +If the two drift apart, plugins break in ways that are hard to trace. + +We lock this down with **golden vectors**. + +> **What's a "golden vector"?** It's a saved example of a known-correct answer: a fixed input paired +> with the exact output it should produce. We compute the outputs *once*, using a neutral third-party +> tool (not our own code), and save them to a file. The test then feeds each input through our code +> and checks it reproduces the saved output. Because the saved answer came from an independent tool +> (an **oracle** — a trusted source of the right answer, here the `openssl` command), the test can't +> "cheat" by agreeing with a bug in our own implementation. And because the backend is verified +> against the *same* saved answers, both sides are pinned to one shared source of truth. + +Concretely: + +- **Signatures:** `test-fixtures/hmac-vectors.json` holds inputs and their correct signatures, + generated with `openssl`. `security/hmac.test.ts` checks our signing reproduces them; the backend + is checked against the same construction. +- **Plugin manifest rules:** `manifest-validation.test.ts` locks in the single set of validation + rules that both the plugin build tool and the host's upload endpoint share, so they can't disagree + about what a valid plugin looks like. + +## Shared helpers & fixtures + +| Path | What it's for | +|------|---------------| +| `app/src/test-support/harness.ts` | Helpers for L2 route tests: build a test app, sign a request, make a config. | +| `test-fixtures/hmac-vectors.json` | The saved signature examples for the L5 contract tests. | +| `test-fixtures/test-plugin/` | The small real plugin compiled and run by the L3 tests. | +| `app/test/wasm/` | The L3 setup that compiles the fixture plugin before the tests. | + +## Conventions + +- Put the **licence header** on every source and test file. +- Put L1/L2 tests **next to the code** they test, named `*.test.ts`. Keep the heavier L3/L4 tests in + `app/test/wasm/` and `app/test/integration/` (named `*.wasm.test.ts` and `*.int.test.ts`) so the + everyday `npm test` never picks them up. +- For anything security-related, generate the expected value with an **independent tool**, never with + the code you're testing. +- When the code does something not-quite-ideal but intentional, **write a passing test that documents + the real behaviour, with a comment explaining it** — that's clearer than no test, and it will start + failing (as a helpful reminder) if someone later changes the behaviour. See "Known behaviours" below. +- Always clean up after a test (close the app, stop the container, delete temp files) so tests don't + interfere with each other. + +## What to write when — a cheat sheet + +| If you change… | Write / update… | Layer | +|----------------|-----------------|-------| +| The request-signature code | golden-vector checks + rejection cases; keep in step with the backend | L5 + L1 | +| A web route or an auth check | a route test: the success case, every rejection, and an unsigned request → 401 | L2 | +| Anything about auth, tokens, or permissions | the failure cases (missing / forged / tampered / expired), and confirm the default is "deny" | L1/L2 | +| The plugin-manifest rules | validation cases; make sure the build tool and the upload endpoint still agree | L1/L5 | +| How the SDK runs a plugin's handlers | a fixture handler + checks by actually running the plugin | **L3** | +| How the host loads/calls/guards a plugin | a plugin-manager test that runs a real plugin (Node 22) | **L3** | +| The plugin → backend callback code | a quick unit test, plus one real-plugin run of the callback | L1 (+L3) | +| Which events get delivered to which plugin | a unit test with a faked message library | L1 | +| Broker connection / reconnect / delivery behaviour | a real-RabbitMQ test | **L4** | +| Database queries, storage, or migrations | a real-Postgres test | **L4** | +| Config / environment-variable parsing | parsing cases (valid + invalid) | L1 | +| The browser-side SDK (messaging, translations) | a happy-dom test; make sure **no token is ever sent out in a message** | L1 | +| Adding a new endpoint that isn't authenticated yet | a test that records the current (open) behaviour, with a TODO to lock it down | L2 | +| Startup/wiring code (`index.ts`) | pull the logic into its own file and unit-test that — don't import `index.ts`, it starts the server | L1 | + +Rule of thumb: **start at L1** and move up only when a lighter test can't reach the risk. Security and +contract changes always get failure-case and golden-vector tests. Changes to how plugins run aren't +proven until a real plugin runs them (L3). + +## How much to test + +- Aim for very high coverage on the security- and contract-critical parts (signing, manifest rules, + the backend callback, event delivery decisions, the browser-side messaging). Keep overall coverage + from slipping over time. +- Don't chase 100%. We don't re-test the backend, we don't test the internals of libraries we depend + on, and we don't test trivial startup glue. A few meaningful security/contract tests are worth more + than padding the number. + +## Continuous integration + +`.github/workflows/plugin_host_ci.yml` runs automatically whenever files under `plugin-host/` change: + +- **`unit`** — type-check + `npm test` (with coverage) for both packages. Runs on every pull request. +- **`wasm`** — downloads the `extism-js` compiler, builds the SDK and fixture, runs the L3 tests. If + you upgrade the `@extism/js-pdk` version in the fixture, bump the matching `EXTISM_JS_VERSION` in + the workflow. +- **`integration`** — runs the L4 tests against the Docker daemon that comes with the CI runner. + +## Known behaviours pinned by tests + +These are real, deliberately-documented behaviours. The tests assert them on purpose; if you fix the +underlying code, update the test to expect the new behaviour. + +- **A plugin handler can't use real `async`/`await`.** Inside the WebAssembly sandbox, a handler that + truly awaits a promise fails with a "did not settle synchronously" error. This is a limitation of + the small JS engine the sandbox uses. In practice plugins don't hit it, because the backend-callback + helper (`gzacApi`) already returns its result directly (the host pauses the plugin while it fetches) + — so authors never need `await`. Documented by the L3 SDK test. +- **The plugin "data" endpoint is capability-gated and user-token-authenticated.** The route a + plugin's iframe uses to fetch its own data carries no HMAC (the caller is a browser, not GZAC); + the host refuses to run the plugin unless the named configuration exists, targets that plugin + version, and was granted the `frontend_data` capability — plus a per-configuration rate limit — + and the request must carry a GZAC-minted downscoped user token, which the host validates by + remote introspection against GZAC and requires to be bound to the named configuration. GZAC + being unreachable fails closed (503) — Wasm never runs on an unvalidated token. The L2 tests pin + the refusals (400/401/403/429/503), the cached-introspection path, and the success path. +- **A plugin with no message broker reads back as `null`.** When a configuration has no broker, the + database stores nothing and reads it back as `null` (rather than "absent"). It's harmless — the code + that uses it treats both the same — but the test documents the real behaviour. Pinned by the L4 + Postgres test. diff --git a/plugin-host/app/.gitignore b/plugin-host/app/.gitignore new file mode 100644 index 0000000000..95e287da47 --- /dev/null +++ b/plugin-host/app/.gitignore @@ -0,0 +1,14 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Test coverage output +coverage/ + +# Temp upload directory +.tmp/ + +# Plugin storage (local dev) +plugins/ diff --git a/plugin-host/app/Dockerfile b/plugin-host/app/Dockerfile new file mode 100644 index 0000000000..e71923dbeb --- /dev/null +++ b/plugin-host/app/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY dist/ ./dist/ + +RUN mkdir -p /data/plugins + +EXPOSE 8090 + +CMD ["node", "dist/index.js"] diff --git a/plugin-host/app/README.md b/plugin-host/app/README.md new file mode 100644 index 0000000000..b24e6b0828 --- /dev/null +++ b/plugin-host/app/README.md @@ -0,0 +1,392 @@ +# Plugin Host + +Node.js + Fastify sidecar that manages and executes external Wasm plugins via [Extism](https://extism.org/). + +## What It Does + +- Accepts plugin `.zip` uploads (containing `manifest.json` + `plugin.wasm`) +- Persists plugins to disk and plugin metadata to PostgreSQL +- Stores plugin configurations in PostgreSQL (survives restarts) +- Executes plugin actions by calling into the Wasm module and returning process variables +- Consumes platform events from RabbitMQ and delivers each to plugins that subscribe to it + (`handle_event`) — see [Events](#events) + +## Project Structure + +``` +src/ + db/ + index.ts # Database pool, migrations + config-repository.ts # CRUD for plugin_configurations table + plugin-repository.ts # CRUD for plugins table + models/ + app-config.ts # AppConfig type + Zod schema + host-logger.ts # HostLogger interface + plugin-configuration.ts # PluginConfiguration interface + plugin-manifest.ts # PluginManifest interface + index.ts # Barrel export + routes/ + health.ts # GET /health + host-management.ts # Plugin CRUD (upload, list, delete) + host-configurations.ts # Configuration push/update/delete + plugin-actions.ts # Action execution + manifest retrieval + plugin-bundles.ts # Static frontend asset serving + rabbitmq/ + event-consumer.ts # Consumes platform events and routes them to subscribed plugins + host-functions/ + gzac-api.ts # Extism host function for GZAC API callbacks + config.ts # Environment config loader + plugin-manager.ts # Wasm lifecycle: load, store, call actions/events via Extism + config-registry.ts # Database-backed configuration store + index.ts # Fastify entry point +docker-compose.yml # PostgreSQL + app containers +Dockerfile # App container image +``` + +## Prerequisites + +- Node.js 22+ +- Docker (for database and containerized deployment) + +## Quick Start (Recommended) + +Run the host locally with only PostgreSQL in Docker. This works seamlessly with GZAC's RabbitMQ +since both use `localhost`. + +```bash +npm install +npm run dev # Starts db container + app with auto-reload +``` + +That's it. The database starts automatically and the host listens on port 8090. + +### Full Docker Deployment + +For production or isolated testing, run everything in Docker: + +```bash +npm run build +ADMIN_TOKEN=my-secret npm run docker:up +``` + +Note: When running fully containerized, GZAC must push `eventBroker.amqpUrl` using +`host.docker.internal` instead of `localhost` to reach the host machine's RabbitMQ. + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `ADMIN_TOKEN` | yes | `changeme` (Docker) | Shared secret used as the HMAC key authenticating every GZAC→host request (see [API Reference](#api-reference)) | +| `PORT` | no | `8090` | HTTP listen port | +| `PLUGIN_STORAGE_DIR` | no | `./plugins` (local), `/data/plugins` (Docker) | Directory for persisted plugin binaries | +| `LOG_LEVEL` | no | `info` | `debug`, `info`, `warn`, or `error` | +| `HOST_ID` | no | OS hostname | Identity of this logical host; names its per-host event queue. Replicas of the **same** host must share one value (see [Events](#events)). | +| `DB_HOST` | no | `localhost` | PostgreSQL host | +| `DB_PORT` | no | `5434` | PostgreSQL port | +| `DB_NAME` | no | `pluginhost` | PostgreSQL database name | +| `DB_USER` | no | `pluginhost` | PostgreSQL username | +| `DB_PASSWORD` | no | `pluginhost` | PostgreSQL password | +| `WASM_TIMEOUT_MS` | no | `30000` | Hard wall-clock limit per Wasm plugin call; Extism cancels the call when exceeded and the route reports a `HOST_ERROR`. | +| `WASM_MAX_MEMORY_PAGES` | no | `4096` | Cap on a plugin's linear memory in 64 KiB pages (default 256 MiB). `0` removes the cap. | +| `WASM_INSTANCE_IDLE_TTL_MS` | no | `600000` | Idle Extism instances are closed after this long without a call (freed worker + memory; next call re-instantiates). `0` disables eviction. | +| `GZAC_API_TIMEOUT_MS` | no | `60000` | Timeout on the `gzac_api` callback fetch into GZAC. | +| `USER_TOKEN_INTROSPECTION_TIMEOUT_MS` | no | `10000` | Timeout on the user-token introspection call the `/plugins/:id/:version/data` route makes against GZAC before executing Wasm. GZAC not answering within it fails the request with a 503 (fail closed). | +| `UPLOAD_MAX_BYTES` | no | `26214400` | Maximum plugin package (.zip) upload size (25 MiB), enforced before the file is buffered for the HMAC check. | +| `DATA_RATE_LIMIT_PER_MINUTE` | no | `120` | Per-configuration request budget for the public `/plugins/:id/:version/data` route. `0` disables the limit. | +| `CONFIG_CACHE_TTL_MS` | no | `10000` | How long configurations are served from the in-memory cache before re-reading Postgres. Writes through this host invalidate immediately. `0` disables caching. | +| `TLS_CERT_PATH` | no | — | PEM certificate. Set **together with** `TLS_KEY_PATH` to make the host serve HTTPS (see [Transport security](#transport-security)). | +| `TLS_KEY_PATH` | no | — | PEM private key. Set together with `TLS_CERT_PATH`. | +| `TLS_CA_PATH` | no | — | PEM CA / intermediate chain, when the certificate file is not self-contained. | + +The host does **not** configure an event broker. Each GZAC instance pushes its own broker connection +alongside every configuration (see [Events](#events)), so one host can serve many GZAC instances, +each on its own broker. + +## Transport security + +Every GZAC→host request is HMAC-SHA256 signed (see [API Reference](#api-reference)). HMAC +authenticates the caller and integrity-binds the request, so a push cannot be forged or replayed — +but it does **not** encrypt the payload. The configuration push carries the broker AMQP URL, its +credentials, and the per-config service token in its body, so confidentiality of those secrets +depends on the transport. + +Set `TLS_CERT_PATH` and `TLS_KEY_PATH` (both together) to make the host serve HTTPS and encrypt the +channel end-to-end; add `TLS_CA_PATH` when the certificate is not self-contained. Both must be set +or the host refuses to start (half-configured TLS would otherwise silently fall back to plain HTTP). +GZAC must then be configured with an `https://` base URL for the host, and the host's certificate +must be trusted by GZAC's JVM truststore (a CA-signed certificate, or the host CA imported into the +truststore). + +Plain HTTP is fine when TLS is terminated by a reverse proxy in front of the host, or for local +development on `localhost`. To keep secrets off an eavesdroppable link, **GZAC refuses to register a +host that carries event-broker credentials unless that host is reachable over HTTPS** (or a loopback +address such as `localhost`/`127.0.0.1` for local development). Hosts without a broker (actions only) +may still be registered over plain HTTP. + +## NPM Scripts + +### Development + +| Script | Description | +|--------|-------------| +| `npm run dev` | Start db container + app with auto-reload (recommended for local dev) | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm start` | Run compiled app | +| `npm run clean` | Remove `dist/`, `.tmp/`, and `plugins/` directories | + +### Database + +| Script | Description | +|--------|-------------| +| `npm run db:up` | Start PostgreSQL container | +| `npm run db:down` | Stop PostgreSQL container | +| `npm run db:reset` | Stop, remove volume, and restart (fresh database) | +| `npm run db:logs` | Follow PostgreSQL logs | +| `npm run db:shell` | Connect to psql shell | + +### Docker + +| Script | Description | +|--------|-------------| +| `npm run docker:build` | Build TypeScript and Docker image | +| `npm run docker:up` | Start full stack (db + app) | +| `npm run docker:down` | Stop all containers | +| `npm run docker:logs` | Follow app container logs | + +## Persistence + +| Data | Storage | Location | +|------|---------|----------| +| Plugin configurations | PostgreSQL | `plugin_configurations` table | +| Plugin metadata | PostgreSQL | `plugins` table | +| Plugin binaries (.wasm, manifest, frontend assets) | Filesystem | `PLUGIN_STORAGE_DIR` (Docker: `/data/plugins` volume) | + +Configurations persist across host restarts. Event consumers automatically reconnect to brokers +referenced by persisted configurations on startup. + +## Events + +A GZAC instance publishes domain events through its transactional outbox as CloudEvents v1.0 JSON to +a RabbitMQ exchange (`valtimo-events`, fanout). Because a single host serves multiple GZAC instances +— each with its own broker — the **host never configures a broker itself**. Instead, each instance +pushes its broker connection (`eventBroker`) alongside every configuration on +`POST/PUT /api/host/configurations/:configId`: + +```json +{ + "pluginId": "case-summary", + "pluginVersion": "0.1.0", + "properties": { "currency": "EUR" }, + "serviceToken": "…", + "gzacBaseUrl": "http://localhost:8080", + "eventBroker": { + "amqpUrl": "amqp://guest:guest@localhost:5672", + "exchange": "valtimo-events", + "exchangeType": "fanout", + "queueMode": "live", + "queueTtlMs": null + } +} +``` + +The host opens **one consumer per distinct broker** and tears it down when no configuration +references it any more. `exchange` defaults to `valtimo-events` and `exchangeType` to `fanout`; omit +`eventBroker` (or its `amqpUrl`) to disable events for a configuration. Each broker's events are +routed only to configurations carrying that same broker. + +**Multiple hosts per instance.** The exchange is a fanout, so the host binds its **own** queue — +`valtimo-external-plugins...`. This means: + +- *Different* hosts on the same GZAC instance each have a distinct queue, so **every host receives a + copy** of every event. +- *Replicas of the same host* (same `HOST_ID`) bind the **same** queue and become competing + consumers, so each event is handled by exactly **one** replica — set a shared `HOST_ID` across + replicas to get this load-balancing (the default OS hostname makes each replica distinct, which + would double-handle). + +**Queue durability modes.** The GZAC admin chooses, per host: + +| `queueMode` | Queue arguments | Behavior | +|-------------|-----------------|----------| +| `live` (default) | `durable:false, autoDelete:true` | Queue evaporates when the host disconnects. Events published while the host is fully down are **lost**. | +| `durable` | `durable:true, autoDelete:false`, `x-expires=queueTtlMs` | Queue survives host restarts. Buffered events are replayed on reconnect, up to `queueTtlMs` of no-consumer inactivity (then the queue is deleted). | + +The mode is included in the queue name, so flipping the mode never collides with the previous +queue's arguments — the old `.live` queue auto-deletes on disconnect, while an orphan `.durable` +queue lingers until `x-expires` fires or an operator deletes it. + +`queueTtlMs` is validated on the GZAC side between 1 hour and 30 days; default 72 hours. Use a +short value (e.g. 1h) for fast local feedback when testing the durability flow; pick a longer one in +production based on the maximum downtime you want to tolerate without losing events. + +Round trip: + +1. A GZAC instance emits an event (e.g. `com.ritense.valtimo.task.completed`, + `com.ritense.valtimo.document.viewed`) → outbox → its `valtimo-events` exchange. +2. The host's consumer for that broker reads the CloudEvent and, for every configuration on that + broker whose manifest lists the event's `type` under `eventSubscriptions`, invokes the plugin's + `handle_event` export. +3. The handler runs in the Extism sandbox with the configuration's properties injected and the + per-configuration service token available, so it can call back into that GZAC instance via + `gzac_api`. + +A plugin declares its subscriptions in `manifest.json`: + +```json +"eventSubscriptions": [ + "com.ritense.valtimo.task.completed", + "com.ritense.valtimo.document.viewed" +] +``` + +and registers a handler with the SDK's `onEvent`: + +```ts +import { onEvent } from "@valtimo/plugin-sdk"; +onEvent((event) => { /* event.type, event.resultId, event.result, ... */ }); +``` + +## API Reference + +Every GZAC→host request is authenticated with an **HMAC-SHA256 signature**, not a bearer token. The +signature is computed over the canonical string `{METHOD}\n{path}\n{timestamp}\n{bodyHash}` keyed +with the `ADMIN_TOKEN`, where: + +- `path` is the request path without the query string; +- `bodyHash` is `SHA-256(body)` hex — the empty string for GET/DELETE, and the **uploaded file + bytes** (not the multipart envelope) for the plugin upload; +- `timestamp` is an ISO-8601 instant; the host rejects anything more than **±5 minutes** from its + own clock. On side-effecting routes (POST/PUT/DELETE) each accepted signature is additionally + **single-use** within that window — the host keeps an in-memory seen-signature cache, so a + captured request replayed verbatim is refused with 401. (Two *distinct* legitimate requests are + never identical: any change to method, path, timestamp or body changes the signature — just use + millisecond-precision timestamps when scripting rapid identical calls.) + +It is sent as two headers: `X-Valtimo-Signature` (the hex HMAC) and `X-Valtimo-Timestamp`. In +production GZAC's `ExternalPluginHostClient` signs every call automatically. To call the host by +hand, sign with this helper (requires `openssl`): + +```bash +ADMIN_TOKEN=test-secret +# host_sign METHOD PATH [BODY_FILE] → sets $TS and $SIG for the curl calls below +host_sign() { + TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + local hash + hash="$(openssl dgst -sha256 -hex "${3:-/dev/null}" | awk '{print $NF}')" + SIG="$(printf '%s\n%s\n%s\n%s' "$1" "$2" "$TS" "$hash" \ + | openssl dgst -sha256 -hmac "$ADMIN_TOKEN" -hex | awk '{print $NF}')" +} +``` + +`GET /health` is the only unauthenticated route. + +### `GET /health` + +```bash +curl -sS http://localhost:8090/health | jq . +``` + +### `GET /api/host/plugins` — list all loaded plugins + +```bash +host_sign GET /api/host/plugins +curl -sS http://localhost:8090/api/host/plugins \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" | jq . +``` + +### `GET /api/host/plugins/:pluginId` — list all versions of a plugin + +```bash +host_sign GET /api/host/plugins/say-hello +curl -sS http://localhost:8090/api/host/plugins/say-hello \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" | jq . +``` + +### `POST /api/host/plugins` — upload plugin `.zip` (multipart) + +The signature binds the **file bytes**, so sign the `.zip` itself: + +```bash +host_sign POST /api/host/plugins ../sample-plugins/say-hello/dist/say-hello-0.1.0.zip +curl -sS -X POST http://localhost:8090/api/host/plugins \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -F "file=@../sample-plugins/say-hello/dist/say-hello-0.1.0.zip" | jq . +``` + +### `DELETE /api/host/plugins/:pluginId/:version` — remove a plugin + +```bash +host_sign DELETE /api/host/plugins/say-hello/0.1.0 +curl -sS -X DELETE http://localhost:8090/api/host/plugins/say-hello/0.1.0 \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" -w "\nHTTP %{http_code}\n" +``` + +### `GET /api/host/configurations` — list all configurations + +```bash +host_sign GET /api/host/configurations +curl -sS http://localhost:8090/api/host/configurations \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" | jq . +``` + +### `POST /api/host/configurations/:configId` — push configuration + +`serviceToken` and `gzacBaseUrl` are required — the host uses them to authenticate and route the +plugin's API callbacks. For local testing any non-empty string works for `serviceToken`. + +Optional grant fields: `grantedCapabilities` (array of `gzac_api` / `http_request` / `kv` / `log` / +`frontend_data`) gates the host functions and the public data route; `grantedEndpoints` (array of +`{"method","pattern"}` Ant-style entries — `*` matches one path segment, `**` any) restricts which +GZAC endpoints `gzac_api` may call. When `grantedEndpoints` is omitted entirely (older GZAC +versions) the host logs a warning and skips its side of the allowlist check — GZAC still enforces +the allowlist server-side; an empty array denies every endpoint. + +Write the body to a file so the signed bytes and the sent bytes match exactly +(`--data-binary @file`): + +```bash +cat > /tmp/config.json <<'JSON' +{"pluginId":"say-hello","pluginVersion":"0.1.0","properties":{"greeting":"Hello"},"serviceToken":"local-test-token","gzacBaseUrl":"http://localhost:8080"} +JSON +host_sign POST /api/host/configurations/my-config /tmp/config.json +curl -sS -X POST http://localhost:8090/api/host/configurations/my-config \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/config.json | jq . +``` + +### `PUT /api/host/configurations/:configId` — update configuration + +```bash +printf '%s' '{"properties":{"greeting":"Hola"}}' > /tmp/config.json +host_sign PUT /api/host/configurations/my-config /tmp/config.json +curl -sS -X PUT http://localhost:8090/api/host/configurations/my-config \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/config.json | jq . +``` + +### `DELETE /api/host/configurations/:configId` — remove configuration + +```bash +host_sign DELETE /api/host/configurations/my-config +curl -sS -X DELETE http://localhost:8090/api/host/configurations/my-config \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" -w "\nHTTP %{http_code}\n" +``` + +### `POST /plugins/:pluginId/:version/actions/:actionKey` — execute an action + +```bash +printf '%s' '{"configurationId":"my-config","processInstanceId":"p1","documentId":"d1","activityId":"a1","properties":{"recipient":"World"}}' > /tmp/action.json +host_sign POST /plugins/say-hello/0.1.0/actions/say-hello /tmp/action.json +curl -sS -X POST http://localhost:8090/plugins/say-hello/0.1.0/actions/say-hello \ + -H "X-Valtimo-Timestamp: $TS" -H "X-Valtimo-Signature: $SIG" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/action.json | jq . +``` + +### `GET /plugins/:pluginId/:version/plugin-manifest` — get plugin manifest + +```bash +curl -sS http://localhost:8090/plugins/say-hello/0.1.0/plugin-manifest | jq . +``` diff --git a/plugin-host/app/docker-compose.yml b/plugin-host/app/docker-compose.yml new file mode 100644 index 0000000000..f9e7fafc24 --- /dev/null +++ b/plugin-host/app/docker-compose.yml @@ -0,0 +1,51 @@ +services: + db: + image: postgres:16-alpine + container_name: plugin-host-db + environment: + POSTGRES_USER: ${POSTGRES_USER:-pluginhost} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pluginhost} + POSTGRES_DB: ${POSTGRES_DB:-pluginhost} + ports: + - "${POSTGRES_PORT:-5434}:5432" + volumes: + - plugin-host-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pluginhost} -d ${POSTGRES_DB:-pluginhost}"] + interval: 5s + timeout: 5s + retries: 5 + + plugin-host: + build: . + container_name: plugin-host-app + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme} + DB_HOST: db + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB:-pluginhost} + DB_USER: ${POSTGRES_USER:-pluginhost} + DB_PASSWORD: ${POSTGRES_PASSWORD:-pluginhost} + PLUGIN_STORAGE_DIR: /data/plugins + HOST_ID: ${HOST_ID:-plugin-host} + LOG_LEVEL: ${LOG_LEVEL:-info} + # Uncomment (and mount the ./tls volume below) to terminate TLS at the host so the GZAC→host + # config push — which carries broker credentials and the service token — is encrypted, not + # only HMAC-authenticated. GZAC must then register this host with an https:// base URL. + # TLS_CERT_PATH: /tls/tls.crt + # TLS_KEY_PATH: /tls/tls.key + # TLS_CA_PATH: /tls/ca.crt + ports: + - "${APP_PORT:-8090}:8090" + volumes: + - plugin-storage:/data/plugins + # - ./tls:/tls:ro + depends_on: + db: + condition: service_healthy + +volumes: + plugin-host-data: + plugin-storage: diff --git a/plugin-host/app/package-lock.json b/plugin-host/app/package-lock.json new file mode 100644 index 0000000000..7eb004dc9a --- /dev/null +++ b/plugin-host/app/package-lock.json @@ -0,0 +1,5227 @@ +{ + "name": "@valtimo/plugin-host", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@valtimo/plugin-host", + "version": "0.1.0", + "license": "EUPL-1.2", + "dependencies": { + "@extism/extism": "^2.0.0-rc13", + "@fastify/multipart": "^10.0.0", + "@valtimo/plugin-sdk": "file:../plugin-sdk", + "adm-zip": "^0.5.17", + "amqplib": "^0.10.9", + "fastify": "^5.8.5", + "fastify-raw-body": "^5.0.0", + "pg": "^8.21.0", + "pino": "^9.6.0", + "undici": "^7.28.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@testcontainers/postgresql": "^10.28.0", + "@testcontainers/rabbitmq": "^10.28.0", + "@types/adm-zip": "^0.5.8", + "@types/amqplib": "^0.10.8", + "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", + "@vitest/coverage-v8": "^3.2.0", + "testcontainers": "^10.28.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../plugin-sdk": { + "name": "@valtimo/plugin-sdk", + "version": "0.1.0", + "license": "EUPL-1.2", + "dependencies": { + "@extism/js-pdk": "^1.1.0", + "adm-zip": "^0.5.17" + }, + "bin": { + "valtimo-plugin-build": "bin/valtimo-plugin-build.mjs", + "valtimo-plugin-pack": "bin/valtimo-plugin-pack.mjs" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.0", + "happy-dom": "^15.0.0", + "typescript": "^5.4.0", + "vitest": "^3.2.0" + }, + "peerDependencies": { + "@extism/js-pdk": "^1.1.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@extism/extism": { + "version": "2.0.0-rc13", + "resolved": "https://registry.npmjs.org/@extism/extism/-/extism-2.0.0-rc13.tgz", + "integrity": "sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==" + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testcontainers/postgresql": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.28.0.tgz", + "integrity": "sha512-NN25rruG5D4Q7pCNIJuHwB+G85OSeJ3xHZ2fWx0O6sPoPEfCYwvpj8mq99cyn68nxFkFYZeyrZJtSFO+FnydiA==", + "dev": true, + "dependencies": { + "testcontainers": "^10.28.0" + } + }, + "node_modules/@testcontainers/rabbitmq": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.28.0.tgz", + "integrity": "sha512-Gl8/gAYfRCsjuhTfAIT7/0e49ozMRe05RDFr2CFQWcZDgB4A7qx2QaUqEIhcQnHqRjfEQTWoV1PovnGNZU+dGQ==", + "dev": true, + "dependencies": { + "testcontainers": "^10.28.0" + } + }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/amqplib": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.8.tgz", + "integrity": "sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.47", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", + "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", + "dev": true, + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/@valtimo/plugin-sdk": { + "resolved": "../plugin-sdk", + "link": true + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/amqplib": { + "version": "0.10.9", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", + "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", + "dependencies": { + "buffer-more-ints": "~1.0.0", + "url-parse": "~1.5.10" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-more-ints": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", + "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/docker-compose": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", + "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "dev": true, + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "dev": true, + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/dockerode/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify-raw-body": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fastify-raw-body/-/fastify-raw-body-5.0.0.tgz", + "integrity": "sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==", + "dependencies": { + "fastify-plugin": "^5.0.0", + "raw-body": "^3.0.0", + "secure-json-parse": "^2.4.0" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "url": "https://github.com/Eomm/fastify-raw-body?sponsor=1" + } + }, + "node_modules/fastify-raw-body/node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "dev": true, + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/properties-reader": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", + "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/testcontainers": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.28.0.tgz", + "integrity": "sha512-1fKrRRCsgAQNkarjHCMKzBKXSJFmzNTiTbhb5E/j5hflRXChEtHvkefjaHlgkNUjfw92/Dq8LTgwQn6RDBFbMg==", + "dev": true, + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.35", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.3.5", + "docker-compose": "^0.24.8", + "dockerode": "^4.0.5", + "get-port": "^7.1.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^2.3.0", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.0.7", + "tmp": "^0.2.3", + "undici": "^5.29.0" + } + }, + "node_modules/testcontainers/node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/testcontainers/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugin-host/app/package.json b/plugin-host/app/package.json new file mode 100644 index 0000000000..630013d248 --- /dev/null +++ b/plugin-host/app/package.json @@ -0,0 +1,58 @@ +{ + "name": "@valtimo/plugin-host", + "version": "0.1.0", + "description": "Valtimo External Plugin Host — Node.js + Fastify + Extism sidecar", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "installDeps": "npm i", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:wasm": "vitest run -c vitest.wasm.config.ts", + "test:int": "vitest run -c vitest.int.config.ts", + "start": "node dist/index.js", + "dev": "npm run db:up && ADMIN_TOKEN=test-secret tsx watch src/index.ts", + "clean": "rm -rf dist .tmp plugins", + "db:up": "docker compose up -d db", + "db:down": "docker compose down", + "db:reset": "docker compose down -v && docker compose up -d db", + "db:logs": "docker compose logs -f db", + "db:shell": "docker compose exec db psql -U pluginhost -d pluginhost", + "docker:build": "npm run build && docker compose build app", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:logs": "docker compose logs -f app" + }, + "dependencies": { + "@extism/extism": "^2.0.0-rc13", + "@fastify/multipart": "^10.0.0", + "@valtimo/plugin-sdk": "file:../plugin-sdk", + "adm-zip": "^0.5.17", + "amqplib": "^0.10.9", + "fastify": "^5.8.5", + "fastify-raw-body": "^5.0.0", + "pg": "^8.21.0", + "pino": "^9.6.0", + "undici": "^7.28.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@testcontainers/postgresql": "^10.28.0", + "@testcontainers/rabbitmq": "^10.28.0", + "@types/adm-zip": "^0.5.8", + "@types/amqplib": "^0.10.8", + "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", + "@vitest/coverage-v8": "^3.2.0", + "testcontainers": "^10.28.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "license": "EUPL-1.2" +} diff --git a/plugin-host/app/src/config-registry.test.ts b/plugin-host/app/src/config-registry.test.ts new file mode 100644 index 0000000000..95da9a9017 --- /dev/null +++ b/plugin-host/app/src/config-registry.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {ConfigRegistry} from "./config-registry"; +import type {PluginConfiguration} from "./models/index.js"; + +function makeConfig(id: string): PluginConfiguration { + return { + configurationId: id, + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: {}, + serviceToken: "svc", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: [], + }; +} + +describe("ConfigRegistry cache", () => { + let repo: { + get: ReturnType; + set: ReturnType; + delete: ReturnType; + list: ReturnType; + listByPlugin: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + repo = { + get: vi.fn(async (id: string) => makeConfig(id)), + set: vi.fn(async () => {}), + delete: vi.fn(async () => true), + list: vi.fn(async () => [makeConfig("cfg-1")]), + listByPlugin: vi.fn(async () => []), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("serves repeated get() calls from the cache within the TTL", async () => { + const registry = new ConfigRegistry(repo as never, 10_000); + await registry.get("cfg-1"); + await registry.get("cfg-1"); + expect(repo.get).toHaveBeenCalledTimes(1); + }); + + it("serves repeated list() calls from the cache within the TTL, and primes get()", async () => { + const registry = new ConfigRegistry(repo as never, 10_000); + await registry.list(); + await registry.list(); + await registry.get("cfg-1"); + expect(repo.list).toHaveBeenCalledTimes(1); + expect(repo.get).not.toHaveBeenCalled(); + }); + + it("re-reads after the TTL expires", async () => { + const registry = new ConfigRegistry(repo as never, 10_000); + await registry.get("cfg-1"); + vi.advanceTimersByTime(10_001); + await registry.get("cfg-1"); + expect(repo.get).toHaveBeenCalledTimes(2); + }); + + it("invalidates on set() so a push is visible immediately", async () => { + const registry = new ConfigRegistry(repo as never, 10_000); + await registry.get("cfg-1"); + await registry.set("cfg-1", makeConfig("cfg-1")); + await registry.get("cfg-1"); + expect(repo.get).toHaveBeenCalledTimes(2); + }); + + it("invalidates on delete()", async () => { + const registry = new ConfigRegistry(repo as never, 10_000); + await registry.list(); + await registry.delete("cfg-1"); + await registry.list(); + expect(repo.list).toHaveBeenCalledTimes(2); + }); + + it("caches negative lookups too (unknown id) within the TTL", async () => { + repo.get.mockResolvedValue(undefined); + const registry = new ConfigRegistry(repo as never, 10_000); + expect(await registry.get("ghost")).toBeUndefined(); + expect(await registry.get("ghost")).toBeUndefined(); + expect(repo.get).toHaveBeenCalledTimes(1); + }); + + it("bypasses the cache entirely when the TTL is 0", async () => { + const registry = new ConfigRegistry(repo as never, 0); + await registry.get("cfg-1"); + await registry.get("cfg-1"); + await registry.list(); + await registry.list(); + expect(repo.get).toHaveBeenCalledTimes(2); + expect(repo.list).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugin-host/app/src/config-registry.ts b/plugin-host/app/src/config-registry.ts new file mode 100644 index 0000000000..5d9248255d --- /dev/null +++ b/plugin-host/app/src/config-registry.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PluginConfiguration } from "./models/index.js"; +import type { ConfigRepository } from "./db/config-repository.js"; + +/** + * Configuration registry backed by database storage. + * + * Maps configurationId → { decrypted properties, plugin routing info }. + * GZAC pushes configurations here on activation; the host injects them + * into every Wasm call. + * + * Configurations are persisted to PostgreSQL and survive host restarts. + * + * Reads are served from a short-TTL in-memory cache so hot paths (one lookup per consumed event + * per configuration, one per data/action call) don't hit Postgres every time. Writes through this + * registry invalidate the cache immediately; a write done by ANOTHER replica against the shared + * database becomes visible after at most `cacheTtlMs`. Pass `cacheTtlMs: 0` to disable caching. + */ +export class ConfigRegistry { + private listCache: { configs: PluginConfiguration[]; expiresAt: number } | null = null; + private readonly entryCache = new Map< + string, + { config: PluginConfiguration | undefined; expiresAt: number } + >(); + + constructor( + private repo: ConfigRepository, + private readonly cacheTtlMs: number = 10_000 + ) {} + + async set(configurationId: string, config: PluginConfiguration): Promise { + await this.repo.set(configurationId, config); + this.invalidate(); + } + + async get(configurationId: string): Promise { + if (this.cacheTtlMs > 0) { + const cached = this.entryCache.get(configurationId); + if (cached && cached.expiresAt > Date.now()) { + return cached.config; + } + } + const config = await this.repo.get(configurationId); + if (this.cacheTtlMs > 0) { + this.entryCache.set(configurationId, { config, expiresAt: Date.now() + this.cacheTtlMs }); + } + return config; + } + + async delete(configurationId: string): Promise { + const deleted = await this.repo.delete(configurationId); + this.invalidate(); + return deleted; + } + + async list(): Promise { + if (this.cacheTtlMs > 0 && this.listCache && this.listCache.expiresAt > Date.now()) { + return this.listCache.configs; + } + const configs = await this.repo.list(); + if (this.cacheTtlMs > 0) { + const expiresAt = Date.now() + this.cacheTtlMs; + this.listCache = { configs, expiresAt }; + // A full read also refreshes the per-id entries, so a get() right after a list() is free. + for (const config of configs) { + this.entryCache.set(config.configurationId, { config, expiresAt }); + } + } + return configs; + } + + async listByPlugin(pluginId: string, pluginVersion: string): Promise { + // Uncached: only used by the (rare) admin plugin-delete guard, where staleness would risk + // deleting a plugin a just-pushed configuration references. + return this.repo.listByPlugin(pluginId, pluginVersion); + } + + /** Drops all cached reads. Called after every write through this registry. */ + invalidate(): void { + this.listCache = null; + this.entryCache.clear(); + } +} diff --git a/plugin-host/app/src/config.ts b/plugin-host/app/src/config.ts new file mode 100644 index 0000000000..dda58a8e35 --- /dev/null +++ b/plugin-host/app/src/config.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { envSchema } from "./models/index.js"; +import type { AppConfig } from "./models/index.js"; + +export type { AppConfig }; + +export function loadConfig(): AppConfig { + return envSchema.parse(process.env); +} diff --git a/plugin-host/app/src/db/config-repository.ts b/plugin-host/app/src/db/config-repository.ts new file mode 100644 index 0000000000..20279f8464 --- /dev/null +++ b/plugin-host/app/src/db/config-repository.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DbPool } from "./index.js"; +import type { PluginConfiguration } from "../models/index.js"; + +export class ConfigRepository { + constructor(private pool: DbPool) {} + + async set(configurationId: string, config: PluginConfiguration): Promise { + await this.pool.query( + `INSERT INTO plugin_configurations + (configuration_id, plugin_id, plugin_version, properties, service_token, gzac_base_url, event_subscriptions, granted_capabilities, granted_endpoints, event_broker, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + ON CONFLICT (configuration_id) DO UPDATE SET + plugin_id = EXCLUDED.plugin_id, + plugin_version = EXCLUDED.plugin_version, + properties = EXCLUDED.properties, + service_token = EXCLUDED.service_token, + gzac_base_url = EXCLUDED.gzac_base_url, + event_subscriptions = EXCLUDED.event_subscriptions, + granted_capabilities = EXCLUDED.granted_capabilities, + granted_endpoints = EXCLUDED.granted_endpoints, + event_broker = EXCLUDED.event_broker, + updated_at = NOW()`, + [ + configurationId, + config.pluginId, + config.pluginVersion, + JSON.stringify(config.properties), + config.serviceToken, + config.gzacBaseUrl, + JSON.stringify(config.eventSubscriptions ?? []), + JSON.stringify(config.grantedCapabilities ?? []), + // NULL (not '[]') when absent: NULL means "not pushed" (older GZAC, allowlist not + // enforced host-side), while '[]' means "pushed and empty" (deny all endpoints). + config.grantedEndpoints ? JSON.stringify(config.grantedEndpoints) : null, + config.eventBroker ? JSON.stringify(config.eventBroker) : null, + ] + ); + } + + async get(configurationId: string): Promise { + const { rows } = await this.pool.query( + `SELECT configuration_id, plugin_id, plugin_version, properties, service_token, gzac_base_url, event_subscriptions, granted_capabilities, granted_endpoints, event_broker + FROM plugin_configurations WHERE configuration_id = $1`, + [configurationId] + ); + + if (rows.length === 0) return undefined; + return this.mapRow(rows[0]); + } + + async delete(configurationId: string): Promise { + const result = await this.pool.query( + "DELETE FROM plugin_configurations WHERE configuration_id = $1", + [configurationId] + ); + return (result.rowCount ?? 0) > 0; + } + + async list(): Promise { + const { rows } = await this.pool.query( + `SELECT configuration_id, plugin_id, plugin_version, properties, service_token, gzac_base_url, event_subscriptions, granted_capabilities, granted_endpoints, event_broker + FROM plugin_configurations ORDER BY created_at` + ); + return rows.map(this.mapRow); + } + + async listByPlugin(pluginId: string, pluginVersion: string): Promise { + const { rows } = await this.pool.query( + `SELECT configuration_id, plugin_id, plugin_version, properties, service_token, gzac_base_url, event_subscriptions, granted_capabilities, granted_endpoints, event_broker + FROM plugin_configurations WHERE plugin_id = $1 AND plugin_version = $2 ORDER BY created_at`, + [pluginId, pluginVersion] + ); + return rows.map(this.mapRow); + } + + private mapRow(row: Record): PluginConfiguration { + return { + configurationId: row.configuration_id as string, + pluginId: row.plugin_id as string, + pluginVersion: row.plugin_version as string, + properties: row.properties as Record, + serviceToken: row.service_token as string, + gzacBaseUrl: row.gzac_base_url as string, + eventSubscriptions: (row.event_subscriptions as string[] | null) ?? [], + grantedCapabilities: (row.granted_capabilities as string[] | null) ?? [], + // NULL round-trips to undefined ("not pushed"); an empty array stays an empty array. + grantedEndpoints: + (row.granted_endpoints as PluginConfiguration["grantedEndpoints"] | null) ?? undefined, + eventBroker: row.event_broker as PluginConfiguration["eventBroker"], + }; + } +} diff --git a/plugin-host/app/src/db/index.ts b/plugin-host/app/src/db/index.ts new file mode 100644 index 0000000000..4192d42b7a --- /dev/null +++ b/plugin-host/app/src/db/index.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import pg from "pg"; +import type { HostLogger } from "../models/index.js"; + +const { Pool } = pg; + +export type DbPool = pg.Pool; + +export interface DbConfig { + host: string; + port: number; + database: string; + user: string; + password: string; +} + +export async function createDbPool( + config: DbConfig, + logger: HostLogger +): Promise { + const pool = new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.user, + password: config.password, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + + pool.on("error", (err) => { + logger.error({ error: err.message }, "Unexpected database pool error"); + }); + + // Verify connection + const client = await pool.connect(); + try { + await client.query("SELECT 1"); + logger.info({ host: config.host, port: config.port, database: config.database }, "Database connected"); + } finally { + client.release(); + } + + return pool; +} + +export async function runMigrations(pool: DbPool, logger: HostLogger): Promise { + const log = logger.child({ component: "migrations" }); + + // Create migrations tracking table + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + const migrations = [ + { + version: 1, + name: "create_plugin_configurations", + up: ` + CREATE TABLE IF NOT EXISTS plugin_configurations ( + configuration_id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + properties JSONB NOT NULL DEFAULT '{}', + service_token TEXT NOT NULL, + gzac_base_url TEXT NOT NULL, + event_broker JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_plugin_configs_plugin ON plugin_configurations(plugin_id, plugin_version); + `, + }, + { + version: 2, + name: "add_event_subscriptions_to_plugin_configurations", + up: ` + ALTER TABLE plugin_configurations + ADD COLUMN IF NOT EXISTS event_subscriptions JSONB NOT NULL DEFAULT '[]'; + `, + }, + { + version: 3, + name: "add_granted_capabilities_to_plugin_configurations", + up: ` + ALTER TABLE plugin_configurations + ADD COLUMN IF NOT EXISTS granted_capabilities JSONB NOT NULL DEFAULT '[]'; + `, + }, + { + version: 4, + name: "create_plugin_kv_and_logs", + up: ` + CREATE TABLE IF NOT EXISTS plugin_kv ( + configuration_id TEXT NOT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (configuration_id, key) + ); + CREATE INDEX IF NOT EXISTS idx_plugin_kv_prefix ON plugin_kv (configuration_id, key text_pattern_ops); + + CREATE TABLE IF NOT EXISTS plugin_logs ( + id BIGSERIAL PRIMARY KEY, + configuration_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + level VARCHAR(8) NOT NULL, + message TEXT NOT NULL, + data JSONB, + source VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_plugin_logs_config ON plugin_logs (configuration_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_plugin_logs_level ON plugin_logs (configuration_id, level, created_at DESC); + `, + }, + { + version: 5, + name: "add_granted_endpoints_to_plugin_configurations", + up: ` + -- NULL (default) means "not pushed" — older GZAC instances don't send granted endpoints, + -- and the host then skips its side of the gzac_api allowlist check (GZAC still enforces + -- it server-side). A pushed empty list ('[]') denies every endpoint. + ALTER TABLE plugin_configurations + ADD COLUMN IF NOT EXISTS granted_endpoints JSONB; + `, + }, + ]; + + for (const migration of migrations) { + const { rows } = await pool.query( + "SELECT 1 FROM schema_migrations WHERE version = $1", + [migration.version] + ); + + if (rows.length === 0) { + log.info({ version: migration.version, name: migration.name }, "Running migration"); + await pool.query(migration.up); + await pool.query("INSERT INTO schema_migrations (version) VALUES ($1)", [migration.version]); + log.info({ version: migration.version, name: migration.name }, "Migration complete"); + } + } +} + +export async function closeDbPool(pool: DbPool): Promise { + await pool.end(); +} diff --git a/plugin-host/app/src/db/kv-repository.ts b/plugin-host/app/src/db/kv-repository.ts new file mode 100644 index 0000000000..e989561f46 --- /dev/null +++ b/plugin-host/app/src/db/kv-repository.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DbPool } from "./index.js"; + +export class KvRepository { + constructor(private pool: DbPool) {} + + async get(configurationId: string, key: string): Promise<{ found: boolean; value: unknown }> { + const { rows } = await this.pool.query( + "SELECT value FROM plugin_kv WHERE configuration_id = $1 AND key = $2", + [configurationId, key] + ); + if (rows.length === 0) return { found: false, value: undefined }; + return { found: true, value: rows[0].value }; + } + + async set(configurationId: string, key: string, value: unknown): Promise { + await this.pool.query( + `INSERT INTO plugin_kv (configuration_id, key, value, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (configuration_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW()`, + [configurationId, key, JSON.stringify(value)] + ); + } + + async delete(configurationId: string, key: string): Promise { + const result = await this.pool.query( + "DELETE FROM plugin_kv WHERE configuration_id = $1 AND key = $2", + [configurationId, key] + ); + return (result.rowCount ?? 0) > 0; + } + + async list(configurationId: string, prefix?: string): Promise { + let query: string; + let params: unknown[]; + if (prefix) { + const escaped = prefix.replace(/[%_\\]/g, "\\$&"); + query = "SELECT key FROM plugin_kv WHERE configuration_id = $1 AND key LIKE $2 ESCAPE '\\' ORDER BY key"; + params = [configurationId, escaped + "%"]; + } else { + query = "SELECT key FROM plugin_kv WHERE configuration_id = $1 ORDER BY key"; + params = [configurationId]; + } + const { rows } = await this.pool.query(query, params); + return rows.map((r: Record) => r.key as string); + } + + async deleteAll(configurationId: string): Promise { + await this.pool.query( + "DELETE FROM plugin_kv WHERE configuration_id = $1", + [configurationId] + ); + } +} diff --git a/plugin-host/app/src/db/log-repository.ts b/plugin-host/app/src/db/log-repository.ts new file mode 100644 index 0000000000..9d7b605b3e --- /dev/null +++ b/plugin-host/app/src/db/log-repository.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DbPool } from "./index.js"; + +export interface PluginLogEntry { + id: number; + configurationId: string; + pluginId: string; + pluginVersion: string; + level: string; + message: string; + data: unknown; + source: string; + createdAt: string; +} + +export interface LogQueryParams { + page: number; + size: number; + level?: string; + source?: string; +} + +export interface LogPage { + content: PluginLogEntry[]; + page: number; + size: number; + totalElements: number; +} + +export class LogRepository { + constructor(private pool: DbPool) {} + + async insert(entry: { + configurationId: string; + pluginId: string; + pluginVersion: string; + level: string; + message: string; + data?: unknown; + source: string; + }): Promise { + await this.pool.query( + `INSERT INTO plugin_logs (configuration_id, plugin_id, plugin_version, level, message, data, source) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + entry.configurationId, + entry.pluginId, + entry.pluginVersion, + entry.level, + entry.message.slice(0, 4096), + entry.data ? JSON.stringify(entry.data) : null, + entry.source, + ] + ); + } + + async query(configurationId: string, params: LogQueryParams): Promise { + const conditions = ["configuration_id = $1"]; + const values: unknown[] = [configurationId]; + let paramIdx = 2; + + if (params.level) { + conditions.push(`level = $${paramIdx}`); + values.push(params.level); + paramIdx++; + } + if (params.source) { + conditions.push(`source = $${paramIdx}`); + values.push(params.source); + paramIdx++; + } + + const where = conditions.join(" AND "); + const limit = Math.max(1, Math.min(params.size || 25, 100)); + const offset = Math.max(0, (params.page || 0)) * limit; + + const countResult = await this.pool.query( + `SELECT COUNT(*) as total FROM plugin_logs WHERE ${where}`, + values + ); + const totalElements = parseInt(countResult.rows[0].total, 10); + + values.push(limit, offset); + const { rows } = await this.pool.query( + `SELECT id, configuration_id, plugin_id, plugin_version, level, message, data, source, created_at + FROM plugin_logs WHERE ${where} + ORDER BY created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + values + ); + + return { + content: rows.map(this.mapRow), + page: params.page, + size: limit, + totalElements, + }; + } + + async deleteOlderThan(days: number): Promise { + const result = await this.pool.query( + `DELETE FROM plugin_logs WHERE created_at < NOW() - INTERVAL '1 day' * $1`, + [days] + ); + return result.rowCount ?? 0; + } + + async deleteByConfiguration(configurationId: string): Promise { + await this.pool.query( + "DELETE FROM plugin_logs WHERE configuration_id = $1", + [configurationId] + ); + } + + private mapRow(row: Record): PluginLogEntry { + return { + id: row.id as number, + configurationId: row.configuration_id as string, + pluginId: row.plugin_id as string, + pluginVersion: row.plugin_version as string, + level: row.level as string, + message: row.message as string, + data: row.data, + source: row.source as string, + createdAt: (row.created_at as Date).toISOString(), + }; + } +} diff --git a/plugin-host/app/src/host-functions/guard.ts b/plugin-host/app/src/host-functions/guard.ts new file mode 100644 index 0000000000..e89c9ea463 --- /dev/null +++ b/plugin-host/app/src/host-functions/guard.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallContext } from "@extism/extism"; +import type { GzacApiCallContext } from "./gzac-api.js"; + +export type HostCallGuardResult = + | { ok: true; ctx: GzacApiCallContext; req: TReq } + | { ok: false; status: 500 | 403 | 400; message: string }; + +/** + * Shared entry guard for every Extism host function: resolves the per-call host context, enforces + * the configuration's granted-capability gate, and parses the plugin's JSON request. Each host + * function maps a failed guard onto its own reply envelope, so this stays shape-agnostic. + */ +export function guardHostCall( + callContext: CallContext, + addr: bigint, + capability: string +): HostCallGuardResult { + const ctx = callContext.hostContext(); + if (!ctx) { + return { ok: false, status: 500, message: "No active invocation context" }; + } + + if (!ctx.grantedCapabilities?.includes(capability)) { + return { + ok: false, + status: 403, + message: `Capability '${capability}' not granted for this configuration`, + }; + } + + const inputJson = callContext.read(addr)?.string() ?? "{}"; + try { + return { ok: true, ctx, req: JSON.parse(inputJson) as TReq }; + } catch (err) { + return { + ok: false, + status: 400, + message: `Invalid ${capability} request JSON: ${(err as Error).message}`, + }; + } +} diff --git a/plugin-host/app/src/host-functions/gzac-api.test.ts b/plugin-host/app/src/host-functions/gzac-api.test.ts new file mode 100644 index 0000000000..cd33174641 --- /dev/null +++ b/plugin-host/app/src/host-functions/gzac-api.test.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {HostLogger} from "../models/index.js"; +import {createGzacApiHostFunction, type GzacApiCallContext} from "./gzac-api"; + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +/** + * Drives the `gzac_api` host function with a fake Extism CallContext: `hostContext()` returns the + * per-call token context, `read(addr)` yields the plugin's request JSON, and `store()` captures the + * reply the plugin would receive. Returns the parsed reply. + */ +function invoke(hostCtx: GzacApiCallContext | undefined, request: unknown) { + const stored: string[] = []; + const inputJson = typeof request === "string" ? request : JSON.stringify(request); + const callContext = { + hostContext: () => hostCtx, + read: (_addr: bigint) => ({ string: () => inputJson }), + store: (s: string) => { + stored.push(s); + return 0n; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const fn = createGzacApiHostFunction(noopLogger()); + return fn(callContext, 0n).then(() => JSON.parse(stored.at(-1)!)); +} + +const baseCtx: GzacApiCallContext = { + configurationId: "cfg-1", + pluginId: "case-summary", + pluginVersion: "0.1.0", + serviceToken: "service-token-abc", + gzacBaseUrl: "http://gzac:8080", + grantedCapabilities: ["gzac_api"], + // No grantedEndpoints: an older push without an endpoint list — the host warns and allows. +}; + +describe("gzac_api host function", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe("credential selection", () => { + it("uses the service token by default", async () => { + await invoke(baseCtx, { method: "GET", path: "/api/v1/foo" }); + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers.Authorization).toBe("Bearer service-token-abc"); + }); + + it("uses the downscoped user token when the request asks for as:'user'", async () => { + await invoke({ ...baseCtx, userToken: "user-token-xyz" }, { + method: "GET", + path: "/api/v1/foo", + as: "user", + }); + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers.Authorization).toBe("Bearer user-token-xyz"); + }); + + it("returns 401 and does NOT fetch when as:'user' but no user token is present", async () => { + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/foo", as: "user" }); + expect(reply.status).toBe(401); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("capability gate", () => { + it("returns 403 and does NOT fetch when the gzac_api capability is not granted", async () => { + const reply = await invoke({ ...baseCtx, grantedCapabilities: ["kv"] }, { + method: "GET", + path: "/api/v1/foo", + }); + expect(reply.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("endpoint allowlist", () => { + const endpoints = [ + { method: "GET", pattern: "/api/v1/document/*" }, + { method: "POST", pattern: "/api/v1/case/**" }, + ]; + + it("allows a call matching a granted endpoint", async () => { + const reply = await invoke({ ...baseCtx, grantedEndpoints: endpoints }, { + method: "GET", + path: "/api/v1/document/123", + }); + expect(reply.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("refuses a call outside the granted endpoints with 403 and does NOT fetch", async () => { + const reply = await invoke({ ...baseCtx, grantedEndpoints: endpoints }, { + method: "DELETE", + path: "/api/v1/document/123", + }); + expect(reply.status).toBe(403); + expect(reply.body.error).toContain("DELETE /api/v1/document/123"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("denies everything when the pushed endpoint list is empty", async () => { + const reply = await invoke({ ...baseCtx, grantedEndpoints: [] }, { + method: "GET", + path: "/api/v1/document/123", + }); + expect(reply.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("warns-and-allows when the configuration carries no endpoint list (older GZAC push)", async () => { + // Backward compatibility: GZAC's server-side allowlist filter still applies. + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/anything" }); + expect(reply.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + }); + + describe("header handling", () => { + it("strips a plugin-supplied Authorization header and keeps the host-attached token", async () => { + await invoke(baseCtx, { + method: "GET", + path: "/api/v1/foo", + headers: { AUTHORIZATION: "Bearer stolen-or-forged", "X-Custom": "yes" }, + }); + const headers = fetchMock.mock.calls[0][1].headers as Record; + // The host's service token wins; the plugin's value is gone under any casing. + expect(headers.Authorization).toBe("Bearer service-token-abc"); + expect(Object.keys(headers).filter((h) => h.toLowerCase() === "authorization")).toEqual([ + "Authorization", + ]); + expect(headers["X-Custom"]).toBe("yes"); + }); + }); + + describe("timeout", () => { + it("passes an abort signal to fetch and maps a timeout to a 504 reply", async () => { + const timeoutError = new DOMException("The operation timed out.", "TimeoutError"); + fetchMock.mockRejectedValueOnce(timeoutError); + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/slow" }); + expect(reply.status).toBe(504); + expect(reply.body.error).toContain("timed out"); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); + }); + + describe("request validation", () => { + it("returns 500 when there is no invocation context", async () => { + const reply = await invoke(undefined, { method: "GET", path: "/api/v1/foo" }); + expect(reply.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 400 on unparseable request JSON", async () => { + const reply = await invoke(baseCtx, "{not json"); + expect(reply.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when method is missing", async () => { + const reply = await invoke(baseCtx, { path: "/api/v1/foo" }); + expect(reply.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when path does not start with '/'", async () => { + const reply = await invoke(baseCtx, { method: "GET", path: "api/v1/foo" }); + expect(reply.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("URL + body handling", () => { + it("joins gzacBaseUrl and path, trimming a trailing slash on the base", async () => { + await invoke({ ...baseCtx, gzacBaseUrl: "http://gzac:8080/" }, { + method: "GET", + path: "/api/v1/foo", + }); + expect(fetchMock.mock.calls[0][0]).toBe("http://gzac:8080/api/v1/foo"); + }); + + it("JSON-encodes an object body and defaults Content-Type to application/json", async () => { + await invoke(baseCtx, { method: "POST", path: "/api/v1/foo", body: { a: 1 } }); + const init = fetchMock.mock.calls[0][1]; + expect(init.body).toBe(JSON.stringify({ a: 1 })); + expect((init.headers as Record)["Content-Type"]).toBe("application/json"); + }); + + it("passes a string body through verbatim without forcing Content-Type", async () => { + await invoke(baseCtx, { method: "POST", path: "/api/v1/foo", body: "raw-text" }); + const init = fetchMock.mock.calls[0][1]; + expect(init.body).toBe("raw-text"); + expect((init.headers as Record)["Content-Type"]).toBeUndefined(); + }); + + it("upper-cases the HTTP method", async () => { + await invoke(baseCtx, { method: "post", path: "/api/v1/foo" }); + expect(fetchMock.mock.calls[0][1].method).toBe("POST"); + }); + }); + + describe("response handling", () => { + it("parses a JSON response body", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ hello: "world" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/foo" }); + expect(reply.status).toBe(200); + expect(reply.body).toEqual({ hello: "world" }); + }); + + it("returns a non-JSON response body as raw text", async () => { + fetchMock.mockResolvedValueOnce(new Response("plain text", { status: 200 })); + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/foo" }); + expect(reply.body).toBe("plain text"); + }); + + it("passes through a non-2xx status from GZAC", async () => { + fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 403 })); + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/foo" }); + expect(reply.status).toBe(403); + }); + + it("returns 502 when the fetch itself throws", async () => { + fetchMock.mockRejectedValueOnce(new Error("connection refused")); + const reply = await invoke(baseCtx, { method: "GET", path: "/api/v1/foo" }); + expect(reply.status).toBe(502); + }); + }); +}); diff --git a/plugin-host/app/src/host-functions/gzac-api.ts b/plugin-host/app/src/host-functions/gzac-api.ts new file mode 100644 index 0000000000..920b895119 --- /dev/null +++ b/plugin-host/app/src/host-functions/gzac-api.ts @@ -0,0 +1,240 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {CallContext} from "@extism/extism"; +import type {Endpoint, HostLogger} from "../models/index.js"; +import {isEndpointAllowed} from "../security/endpoint-allowlist.js"; +import {guardHostCall} from "./guard.js"; + +/** + * Per-call context the host attaches to every `plugin.call(...)`. Made available to host + * functions via `callContext.hostContext()`. + */ +export interface GzacApiCallContext { + configurationId: string; + pluginId: string; + pluginVersion: string; + serviceToken: string; + gzacBaseUrl: string; + /** + * Downscoped user token forwarded from a tab's `handle_request` invocation. Present only when the + * tab forwarded it; absent for action/event invocations. Used when a request asks for `as:"user"`. + */ + userToken?: string; + /** + * Host capabilities the admin granted at activation. Each host function checks this list before + * executing. A configuration must explicitly include the required capability. + */ + grantedCapabilities?: string[]; + /** + * GZAC endpoints the admin granted at activation (Ant-style patterns; see + * `security/endpoint-allowlist.ts`). Requests outside this list are refused before the fetch. + * `undefined` means the owning GZAC instance didn't push an endpoint list (older push) — the + * host then warns and allows, relying on GZAC's server-side allowlist filter alone. + */ + grantedEndpoints?: Endpoint[]; +} + +interface GzacApiRequest { + method: string; + path: string; + body?: unknown; + headers?: Record; + /** `"user"` → authenticate with the downscoped user token; otherwise the service token. */ + as?: "user" | "service"; +} + +interface GzacApiResponse { + status: number; + headers: Record; + body: unknown; +} + +/** + * Builds the Extism host function entry registered as `extism:host/user::gzac_api`. Plugins call + * this to make an authenticated callback into the GZAC instance that owns their configuration. + * + * The plugin sends a JSON request `{ method, path, body?, headers? }`; the host returns a JSON + * response `{ status, headers, body }`. `body` is parsed as JSON when GZAC responds with parseable + * JSON, otherwise returned as raw text. + * + * Note: this function is async — it requires Extism plugins to run with `runInWorker: true` (see + * `plugin-manager.ts`) so that async host functions work on Node versions without JSPI. + */ +export function createGzacApiHostFunction( + logger: HostLogger, + options: { timeoutMs?: number } = {} +): (callContext: CallContext, addr: bigint) => Promise { + const log = logger.child({ component: "gzac_api" }); + const timeoutMs = options.timeoutMs ?? 60_000; + + return async (callContext: CallContext, addr: bigint): Promise => { + const guard = guardHostCall(callContext, addr, "gzac_api"); + if (!guard.ok) { + return callContext.store(JSON.stringify(errorReply(guard.status, guard.message))); + } + const { ctx, req } = guard; + + if (!req.method || typeof req.method !== "string") { + return callContext.store( + JSON.stringify(errorReply(400, "Missing 'method' in gzac_api request")) + ); + } + if (!req.path || typeof req.path !== "string" || !req.path.startsWith("/")) { + return callContext.store( + JSON.stringify( + errorReply(400, "'path' must be set and start with '/' in gzac_api request") + ) + ); + } + + // Enforce the granted-endpoint allowlist before anything leaves the host. GZAC's servlet + // filter is the authoritative gate; this check refuses non-granted callbacks early. A config + // without an endpoint list (older GZAC push) is allowed with a warning — see + // GzacApiCallContext.grantedEndpoints. + if (ctx.grantedEndpoints === undefined) { + log.warn( + { configurationId: ctx.configurationId, method: req.method, path: req.path }, + "Configuration carries no granted-endpoint list (older GZAC push) — allowing gzac_api call without host-side allowlist check" + ); + } else if (!isEndpointAllowed(req.method, req.path, ctx.grantedEndpoints)) { + log.warn( + { configurationId: ctx.configurationId, method: req.method, path: req.path }, + "gzac_api call refused: endpoint not in the configuration's granted allowlist" + ); + return callContext.store( + JSON.stringify( + errorReply( + 403, + `Endpoint not granted for this configuration: ${req.method.toUpperCase()} ${req.path}` + ) + ) + ); + } + + // Select the credential: the downscoped user token (PBAC ∩ allowlist) when the plugin asked for + // `as:"user"`, otherwise the service token (system credential, allowlist-only). + let token = ctx.serviceToken; + if (req.as === "user") { + if (!ctx.userToken) { + return callContext.store( + JSON.stringify( + errorReply(401, "No user token available for this invocation (as:\"user\" requires a tab request that forwarded the user token)") + ) + ); + } + token = ctx.userToken; + } + + const url = `${ctx.gzacBaseUrl.replace(/\/$/, "")}${req.path}`; + // Plugin-supplied headers first, host-controlled credentials LAST — so a plugin can never + // override the Authorization header the host attaches. Any Authorization the plugin sends is + // stripped explicitly (and logged) rather than silently shadowed. + const pluginHeaders: Record = { ...(req.headers ?? {}) }; + for (const name of Object.keys(pluginHeaders)) { + if (name.toLowerCase() === "authorization") { + log.warn( + { configurationId: ctx.configurationId, pluginId: ctx.pluginId, path: req.path }, + "Stripping plugin-supplied Authorization header from gzac_api request" + ); + delete pluginHeaders[name]; + } + } + const headers: Record = { + Accept: "application/json", + ...pluginHeaders, + Authorization: `Bearer ${token}`, + }; + let bodyInit: BodyInit | undefined; + if (req.body !== undefined && req.body !== null) { + if (typeof req.body === "string") { + bodyInit = req.body; + } else { + if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) { + headers["Content-Type"] = "application/json"; + } + bodyInit = JSON.stringify(req.body); + } + } + + const start = Date.now(); + log.info( + { + configurationId: ctx.configurationId, + pluginId: ctx.pluginId, + pluginVersion: ctx.pluginVersion, + method: req.method, + path: req.path, + }, + "gzac_api call" + ); + + try { + const res = await fetch(url, { + method: req.method.toUpperCase(), + headers, + body: bodyInit, + // Bound the callback so a hung GZAC endpoint can't pin the plugin call (and its + // per-plugin lock) indefinitely. + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await res.text(); + let body: unknown = text; + if (text.length > 0) { + try { + body = JSON.parse(text); + } catch { + // keep raw text + } + } + const out: GzacApiResponse = { + status: res.status, + headers: Object.fromEntries(res.headers.entries()), + body, + }; + log.info( + { method: req.method, url, status: res.status, durationMs: Date.now() - start }, + "gzac_api response" + ); + return callContext.store(JSON.stringify(out)); + } catch (err) { + log.warn( + { method: req.method, url, error: (err as Error).message, durationMs: Date.now() - start }, + "gzac_api error" + ); + // AbortSignal.timeout rejects with a TimeoutError DOMException — report it distinctly so + // plugins can tell a slow GZAC from an unreachable one. + if ((err as Error).name === "TimeoutError" || (err as Error).name === "AbortError") { + return callContext.store( + JSON.stringify( + errorReply(504, `gzac_api request timed out after ${timeoutMs}ms: ${req.method.toUpperCase()} ${req.path}`) + ) + ); + } + return callContext.store( + JSON.stringify(errorReply(502, `gzac_api fetch failed: ${(err as Error).message}`)) + ); + } + }; +} + +function errorReply(status: number, message: string): GzacApiResponse { + return { + status, + headers: { "content-type": "application/json" }, + body: { error: message }, + }; +} diff --git a/plugin-host/app/src/host-functions/http-request.test.ts b/plugin-host/app/src/host-functions/http-request.test.ts new file mode 100644 index 0000000000..163268b25e --- /dev/null +++ b/plugin-host/app/src/host-functions/http-request.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, it} from "vitest"; +import {redactUrl} from "./http-request"; + +describe("redactUrl", () => { + it("strips the query string (tokens routinely travel there)", () => { + expect(redactUrl("https://api.example.com/v1/items?apiKey=SECRET&x=1")).toBe( + "https://api.example.com/v1/items" + ); + }); + + it("strips userinfo credentials", () => { + expect(redactUrl("https://user:hunter2@api.example.com/v1/items")).toBe( + "https://api.example.com/v1/items" + ); + }); + + it("strips fragments and keeps the port", () => { + expect(redactUrl("https://api.example.com:8443/v1/items#section?x=1")).toBe( + "https://api.example.com:8443/v1/items" + ); + }); + + it("degrades gracefully for an unparseable URL", () => { + expect(redactUrl("not a url?secret=1")).toBe("not a url"); + }); +}); diff --git a/plugin-host/app/src/host-functions/http-request.ts b/plugin-host/app/src/host-functions/http-request.ts new file mode 100644 index 0000000000..66b1ad11ee --- /dev/null +++ b/plugin-host/app/src/host-functions/http-request.ts @@ -0,0 +1,296 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Agent, fetch } from "undici"; +import type { Response } from "undici"; +import type { CallContext } from "@extism/extism"; +import type { HostLogger } from "../models/index.js"; +import type { LogRepository } from "../db/log-repository.js"; +import { guardHostCall } from "./guard.js"; +import { + createGuardedAgent, + findBlockedIpLiteral, + isPrivateAddressError, + rootCauseMessage, +} from "../security/url-guard.js"; + +interface HttpRequestInput { + method: string; + url: string; + headers?: Record; + body?: unknown; + timeoutMs?: number; +} + +interface HttpRequestOutput { + status: number; + headers: Record; + body: unknown; +} + +/** + * Strips credentials from a URL before it is logged or persisted: userinfo and the query string + * (and fragment) routinely carry secrets (`user:pass@`, `?token=…`), so only scheme+host+path are + * recorded. + */ +export function redactUrl(raw: string): string { + try { + const url = new URL(raw); + return `${url.protocol}//${url.host}${url.pathname}`; + } catch { + return raw.split(/[?#]/)[0]; + } +} + +const MAX_TIMEOUT_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +export function createHttpRequestHostFunction( + logger: HostLogger, + logRepository: LogRepository, + allowHttp: boolean, + allowPrivateNetwork: boolean +): (callContext: CallContext, addr: bigint) => Promise { + const log = logger.child({ component: "http_request" }); + + // The guarded agent blocks connections to private/reserved addresses at the socket's own DNS + // lookup, so hostname checks are pinned to the exact addresses being connected to (no DNS + // rebinding window) and automatically cover every redirect hop. + const dispatcher = allowPrivateNetwork ? new Agent() : createGuardedAgent(); + + /** + * Validates a request target. Applied to the initial URL AND to every redirect hop, so a public + * URL cannot 3xx the host into the GZAC instance or an internal service. + */ + const validateTarget = (url: URL, gzacBaseUrl: string | undefined): string | null => { + if (url.protocol !== "https:" && url.protocol !== "http:") { + return "Only http(s) URLs are supported"; + } + if (!allowHttp && url.protocol !== "https:") { + return "Only HTTPS URLs are allowed (set HOST_ALLOW_HTTP=true for dev)"; + } + + // Block calls to the GZAC instance — use gzac_api for that. + if (gzacBaseUrl) { + try { + if (url.origin === new URL(gzacBaseUrl).origin) { + return "Use gzac_api to call the GZAC instance, not http_request"; + } + } catch { + // gzacBaseUrl unparseable — skip the check + } + } + + // IP-literal hosts skip DNS, so the guarded agent's lookup never sees them — reject here. + if (!allowPrivateNetwork) { + const violation = findBlockedIpLiteral(url); + if (violation) { + return `${violation} (set HOST_ALLOW_PRIVATE_NETWORK=true for dev)`; + } + } + + return null; + }; + + return async (callContext: CallContext, addr: bigint): Promise => { + const guard = guardHostCall(callContext, addr, "http_request"); + if (!guard.ok) { + return callContext.store(JSON.stringify(errorReply(guard.status, guard.message))); + } + const { ctx, req } = guard; + + if (!req.method || typeof req.method !== "string") { + return callContext.store(JSON.stringify(errorReply(400, "Missing 'method'"))); + } + if (!req.url || typeof req.url !== "string") { + return callContext.store(JSON.stringify(errorReply(400, "Missing 'url'"))); + } + + let parsed: URL; + try { + parsed = new URL(req.url); + } catch { + return callContext.store(JSON.stringify(errorReply(400, "Invalid URL"))); + } + + const targetError = validateTarget(parsed, ctx.gzacBaseUrl); + if (targetError) { + return callContext.store(JSON.stringify(errorReply(400, targetError))); + } + + const timeoutMs = Math.min(req.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); + + const headers: Record = { + Accept: "application/json", + ...(req.headers ?? {}), + }; + let bodyInit: string | undefined; + if (req.body !== undefined && req.body !== null) { + if (typeof req.body === "string") { + bodyInit = req.body; + } else { + if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) { + headers["Content-Type"] = "application/json"; + } + bodyInit = JSON.stringify(req.body); + } + } + + const start = Date.now(); + // Logged/persisted URLs are redacted (no userinfo, no query string) — see redactUrl. + const safeUrl = redactUrl(req.url); + log.info( + { configurationId: ctx.configurationId, method: req.method, url: safeUrl }, + "http_request call" + ); + + try { + let currentUrl = parsed; + let method = req.method.toUpperCase(); + let currentHeaders = headers; + let currentBody = bodyInit; + let redirects = 0; + let res: Response; + + // Redirects are followed manually so every hop goes through validateTarget — with + // redirect: "follow" a public URL could bounce the request to an internal one unchecked. + for (;;) { + res = await fetch(currentUrl, { + method, + headers: currentHeaders, + body: currentBody, + signal: AbortSignal.timeout(timeoutMs), + redirect: "manual", + dispatcher, + }); + + const location = res.headers.get("location"); + if (!REDIRECT_STATUSES.has(res.status) || !location) break; + await res.body?.cancel(); + + if (redirects >= MAX_REDIRECTS) { + return callContext.store( + JSON.stringify(errorReply(502, `Too many redirects (max ${MAX_REDIRECTS})`)) + ); + } + redirects++; + + let next: URL; + try { + next = new URL(location, currentUrl); + } catch { + return callContext.store( + JSON.stringify(errorReply(502, `Invalid redirect location: ${location}`)) + ); + } + + const redirectError = validateTarget(next, ctx.gzacBaseUrl); + if (redirectError) { + return callContext.store( + JSON.stringify(errorReply(400, `Redirect to '${next}' blocked: ${redirectError}`)) + ); + } + + // Per fetch semantics: 303 — and 301/302 for body-bearing methods — becomes a GET without body. + if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) { + method = "GET"; + currentBody = undefined; + } + // Never forward credentials to a different origin. + if (next.origin !== currentUrl.origin) { + currentHeaders = Object.fromEntries( + Object.entries(currentHeaders).filter( + ([name]) => !["authorization", "cookie", "proxy-authorization"].includes(name.toLowerCase()) + ) + ); + } + currentUrl = next; + } + + const text = await res.text(); + let body: unknown = text; + if (text.length > 0) { + try { + body = JSON.parse(text); + } catch { + // keep raw text + } + } + + const durationMs = Date.now() - start; + const out: HttpRequestOutput = { + status: res.status, + headers: Object.fromEntries(res.headers.entries()), + body, + }; + + log.info({ method: req.method, url: safeUrl, status: res.status, durationMs }, "http_request response"); + + logRepository + .insert({ + configurationId: ctx.configurationId, + pluginId: ctx.pluginId, + pluginVersion: ctx.pluginVersion, + level: "info", + message: `${req.method.toUpperCase()} ${safeUrl} → ${res.status}`, + data: { method: req.method, url: safeUrl, status: res.status, durationMs }, + source: "http_request", + }) + .catch((e) => log.warn({ error: (e as Error).message }, "Failed to persist http_request log")); + + return callContext.store(JSON.stringify(out)); + } catch (err) { + const durationMs = Date.now() - start; + // undici wraps connection failures in a generic "fetch failed" — report the real reason. + const errMsg = rootCauseMessage(err); + log.warn({ method: req.method, url: safeUrl, error: errMsg, durationMs }, "http_request error"); + + logRepository + .insert({ + configurationId: ctx.configurationId, + pluginId: ctx.pluginId, + pluginVersion: ctx.pluginVersion, + level: "error", + message: `${req.method.toUpperCase()} ${safeUrl} → error: ${errMsg}`, + data: { method: req.method, url: safeUrl, error: errMsg, durationMs }, + source: "http_request", + }) + .catch((e) => log.warn({ error: (e as Error).message }, "Failed to persist http_request log")); + + // The guarded agent refusing a private/reserved target is a policy rejection, not an + // upstream failure — report it like the other validation errors. + if (isPrivateAddressError(err)) { + return callContext.store( + JSON.stringify(errorReply(400, `${errMsg} (set HOST_ALLOW_PRIVATE_NETWORK=true for dev)`)) + ); + } + + return callContext.store( + JSON.stringify(errorReply(502, `http_request failed: ${errMsg}`)) + ); + } + }; +} + +function errorReply(status: number, message: string): HttpRequestOutput { + return { + status, + headers: { "content-type": "application/json" }, + body: { error: message }, + }; +} diff --git a/plugin-host/app/src/host-functions/kv.ts b/plugin-host/app/src/host-functions/kv.ts new file mode 100644 index 0000000000..bb7241ab0b --- /dev/null +++ b/plugin-host/app/src/host-functions/kv.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallContext } from "@extism/extism"; +import type { HostLogger } from "../models/index.js"; +import type { KvRepository } from "../db/kv-repository.js"; +import { guardHostCall } from "./guard.js"; + +interface KvRequest { + op: string; + key?: string; + value?: unknown; + prefix?: string; +} + +export function createKvHostFunction( + logger: HostLogger, + kvRepository: KvRepository +): (callContext: CallContext, addr: bigint) => Promise { + const log = logger.child({ component: "kv" }); + + return async (callContext: CallContext, addr: bigint): Promise => { + const guard = guardHostCall(callContext, addr, "kv"); + if (!guard.ok) { + return callContext.store(JSON.stringify({ status: guard.status, error: guard.message })); + } + const { ctx, req } = guard; + + try { + switch (req.op) { + case "get": { + if (!req.key) { + return callContext.store(JSON.stringify({ status: 400, error: "Missing 'key' for kv get" })); + } + const result = await kvRepository.get(ctx.configurationId, req.key); + return callContext.store( + JSON.stringify(result.found ? { status: 200, value: result.value } : { status: 404 }) + ); + } + case "set": { + if (!req.key) { + return callContext.store(JSON.stringify({ status: 400, error: "Missing 'key' for kv set" })); + } + if (req.key.length > 256) { + return callContext.store(JSON.stringify({ status: 400, error: "Key exceeds 256 characters" })); + } + await kvRepository.set(ctx.configurationId, req.key, req.value); + log.debug({ configurationId: ctx.configurationId, key: req.key }, "kv set"); + return callContext.store(JSON.stringify({ status: 200 })); + } + case "delete": { + if (!req.key) { + return callContext.store(JSON.stringify({ status: 400, error: "Missing 'key' for kv delete" })); + } + const deleted = await kvRepository.delete(ctx.configurationId, req.key); + return callContext.store(JSON.stringify({ status: deleted ? 200 : 404 })); + } + case "list": { + const keys = await kvRepository.list(ctx.configurationId, req.prefix); + return callContext.store(JSON.stringify({ status: 200, keys })); + } + default: + return callContext.store( + JSON.stringify({ status: 400, error: `Unknown kv op: ${req.op}` }) + ); + } + } catch (err) { + log.warn({ error: (err as Error).message, op: req.op }, "kv error"); + return callContext.store( + JSON.stringify({ status: 500, error: `kv operation failed: ${(err as Error).message}` }) + ); + } + }; +} diff --git a/plugin-host/app/src/host-functions/log.ts b/plugin-host/app/src/host-functions/log.ts new file mode 100644 index 0000000000..5394a8a686 --- /dev/null +++ b/plugin-host/app/src/host-functions/log.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallContext } from "@extism/extism"; +import type { HostLogger } from "../models/index.js"; +import type { LogRepository } from "../db/log-repository.js"; +import { guardHostCall } from "./guard.js"; + +interface LogRequest { + level: string; + message: string; + data?: Record; +} + +export function createLogHostFunction( + logger: HostLogger, + logRepository: LogRepository +): (callContext: CallContext, addr: bigint) => Promise { + const hostLog = logger.child({ component: "plugin_log" }); + + return async (callContext: CallContext, addr: bigint): Promise => { + const guard = guardHostCall(callContext, addr, "log"); + if (!guard.ok) { + return callContext.store(JSON.stringify({ status: guard.status, error: guard.message })); + } + const { ctx, req } = guard; + + const level = ["info", "warn", "error", "debug"].includes(req.level) ? req.level : "info"; + const message = (req.message ?? "").slice(0, 4096); + + const logData = { + configurationId: ctx.configurationId, + pluginId: ctx.pluginId, + pluginVersion: ctx.pluginVersion, + ...(req.data ?? {}), + }; + + switch (level) { + case "warn": + hostLog.warn(logData, message); + break; + case "error": + hostLog.error(logData, message); + break; + case "debug": + hostLog.debug(logData, message); + break; + default: + hostLog.info(logData, message); + } + + logRepository + .insert({ + configurationId: ctx.configurationId, + pluginId: ctx.pluginId, + pluginVersion: ctx.pluginVersion, + level, + message, + data: req.data, + source: "plugin", + }) + .catch((err) => { + hostLog.warn({ error: (err as Error).message }, "Failed to persist plugin log entry"); + }); + + return callContext.store(JSON.stringify({ status: 200 })); + }; +} diff --git a/plugin-host/app/src/https-options.test.ts b/plugin-host/app/src/https-options.test.ts new file mode 100644 index 0000000000..23a695c297 --- /dev/null +++ b/plugin-host/app/src/https-options.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {afterAll, beforeAll, describe, expect, it} from "vitest"; +import type {AppConfig} from "./models/index.js"; +import {buildHttpsOptions} from "./https-options"; + +function configWith(tls: Partial): AppConfig { + return { ...tls } as AppConfig; +} + +describe("buildHttpsOptions", () => { + let dir: string; + let certPath: string; + let keyPath: string; + let caPath: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "plugin-host-tls-")); + certPath = join(dir, "cert.pem"); + keyPath = join(dir, "key.pem"); + caPath = join(dir, "ca.pem"); + writeFileSync(certPath, "CERT-BYTES"); + writeFileSync(keyPath, "KEY-BYTES"); + writeFileSync(caPath, "CA-BYTES"); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns undefined when neither cert nor key is configured (plain HTTP)", () => { + expect(buildHttpsOptions(configWith({}))).toBeUndefined(); + }); + + it("throws when only the cert is configured", () => { + expect(() => buildHttpsOptions(configWith({ TLS_CERT_PATH: certPath }))).toThrow( + /half-configured/ + ); + }); + + it("throws when only the key is configured", () => { + expect(() => buildHttpsOptions(configWith({ TLS_KEY_PATH: keyPath }))).toThrow( + /half-configured/ + ); + }); + + it("reads cert + key when both are configured", () => { + const opts = buildHttpsOptions(configWith({ TLS_CERT_PATH: certPath, TLS_KEY_PATH: keyPath })); + expect(opts?.cert?.toString()).toBe("CERT-BYTES"); + expect(opts?.key?.toString()).toBe("KEY-BYTES"); + expect(opts?.ca).toBeUndefined(); + }); + + it("includes the CA chain when TLS_CA_PATH is set", () => { + const opts = buildHttpsOptions( + configWith({ TLS_CERT_PATH: certPath, TLS_KEY_PATH: keyPath, TLS_CA_PATH: caPath }) + ); + expect(opts?.ca?.toString()).toBe("CA-BYTES"); + }); +}); diff --git a/plugin-host/app/src/https-options.ts b/plugin-host/app/src/https-options.ts new file mode 100644 index 0000000000..47becfdda4 --- /dev/null +++ b/plugin-host/app/src/https-options.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {readFileSync} from "node:fs"; +import type {ServerOptions as HttpsServerOptions} from "node:https"; +import type {AppConfig} from "./models/index.js"; + +/** + * Reads the TLS material when the host is configured to terminate HTTPS itself. Both the + * certificate and key must be set together; supplying only one is a misconfiguration that would + * otherwise silently fall back to plain HTTP, so it fails fast. + */ +export function buildHttpsOptions(config: AppConfig): HttpsServerOptions | undefined { + const { TLS_CERT_PATH, TLS_KEY_PATH, TLS_CA_PATH } = config; + if (!TLS_CERT_PATH && !TLS_KEY_PATH) { + return undefined; + } + if (!TLS_CERT_PATH || !TLS_KEY_PATH) { + throw new Error( + "TLS is half-configured: set both TLS_CERT_PATH and TLS_KEY_PATH (PEM files), or neither." + ); + } + return { + cert: readFileSync(TLS_CERT_PATH), + key: readFileSync(TLS_KEY_PATH), + ...(TLS_CA_PATH ? { ca: readFileSync(TLS_CA_PATH) } : {}), + }; +} diff --git a/plugin-host/app/src/index.ts b/plugin-host/app/src/index.ts new file mode 100644 index 0000000000..db3b997919 --- /dev/null +++ b/plugin-host/app/src/index.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Fastify, {type FastifyServerOptions} from "fastify"; +import rawBody from "fastify-raw-body"; +import multipart from "@fastify/multipart"; +import {loadConfig} from "./config.js"; +import {buildHttpsOptions} from "./https-options.js"; +import {PluginManager} from "./plugin-manager.js"; +import {ConfigRegistry} from "./config-registry.js"; +import {healthRoutes} from "./routes/health.js"; +import {hostManagementRoutes} from "./routes/host-management.js"; +import {hostConfigurationRoutes} from "./routes/host-configurations.js"; +import {pluginActionRoutes} from "./routes/plugin-actions.js"; +import {pluginSubmitRoutes} from "./routes/plugin-submit.js"; +import {pluginBundleRoutes} from "./routes/plugin-bundles.js"; +import {pluginDataRoutes} from "./routes/plugin-data.js"; +import {EventConsumerManager} from "./rabbitmq/event-consumer.js"; +import {closeDbPool, createDbPool, type DbPool, runMigrations} from "./db/index.js"; +import {ConfigRepository} from "./db/config-repository.js"; +import {KvRepository} from "./db/kv-repository.js"; +import {LogRepository} from "./db/log-repository.js"; +import {pluginLogRoutes} from "./routes/plugin-logs.js"; + +async function main(): Promise { + const config = loadConfig(); + + // When TLS is configured the host serves HTTPS, encrypting the config push (broker credentials + + // service token) end-to-end; otherwise it serves plain HTTP and relies on a TLS-terminating proxy + // or a loopback/localhost deployment. + const httpsOptions = buildHttpsOptions(config); + const fastify = Fastify({ + logger: { + level: config.LOG_LEVEL, + }, + ...(httpsOptions ? { https: httpsOptions } : {}), + } as FastifyServerOptions); + + // Initialize database connection + let dbPool: DbPool; + try { + dbPool = await createDbPool( + { + host: config.DB_HOST, + port: config.DB_PORT, + database: config.DB_NAME, + user: config.DB_USER, + password: config.DB_PASSWORD, + }, + fastify.log + ); + await runMigrations(dbPool, fastify.log); + } catch (err) { + fastify.log.error({ error: (err as Error).message }, "Failed to connect to database"); + process.exit(1); + } + + // Register raw body plugin for HMAC verification on action routes + await fastify.register(rawBody, { + field: "rawBody", + global: false, // Only enable on routes that request it via config.rawBody + encoding: false, // Return Buffer, not string + runFirst: true, // Run before JSON parsing + }); + + // Register multipart for file uploads. The cap applies BEFORE the upload route buffers the file + // for its HMAC check, so an unauthenticated caller can't make the host buffer huge payloads. + await fastify.register(multipart, { + limits: { + fileSize: config.UPLOAD_MAX_BYTES, + }, + }); + + // Initialize repositories + const configRepository = new ConfigRepository(dbPool); + const kvRepository = new KvRepository(dbPool); + const logRepository = new LogRepository(dbPool); + + // Initialize config registry and plugin manager. The registry fronts the repository with a + // short-TTL cache; the manager reads per-call grants through it so hot paths stay off Postgres. + const configRegistry = new ConfigRegistry(configRepository, config.CONFIG_CACHE_TTL_MS); + const allowHttp = (process.env.HOST_ALLOW_HTTP ?? "").toLowerCase() === "true"; + // Lets http_request reach loopback/private-network targets. Local development only — in + // production this would let a plugin use the host as a proxy into the internal network (SSRF). + const allowPrivateNetwork = (process.env.HOST_ALLOW_PRIVATE_NETWORK ?? "").toLowerCase() === "true"; + const pluginManager = new PluginManager( + config.PLUGIN_STORAGE_DIR, + fastify.log, + configRegistry, + kvRepository, + logRepository, + { + allowHttp, + allowPrivateNetwork, + wasmTimeoutMs: config.WASM_TIMEOUT_MS, + wasmMaxMemoryPages: config.WASM_MAX_MEMORY_PAGES, + gzacApiTimeoutMs: config.GZAC_API_TIMEOUT_MS, + instanceIdleTtlMs: config.WASM_INSTANCE_IDLE_TTL_MS, + } + ); + + // Brokers are learned from the configurations GZAC pushes; the manager opens/closes consumers as + // configurations come and go (see hostConfigurationRoutes). + const eventConsumerManager = new EventConsumerManager( + pluginManager, + configRegistry, + config.HOST_ID, + fastify.log + ); + + // Load existing plugins from disk + await pluginManager.loadAllFromDisk(); + + // Sync event consumers with persisted configurations + await eventConsumerManager.sync(); + + // Register routes + await fastify.register(healthRoutes); + await fastify.register(hostManagementRoutes, { + pluginManager, + configRegistry, + config, + }); + await fastify.register(hostConfigurationRoutes, { + configRegistry, + pluginManager, + config, + eventConsumerManager, + }); + await fastify.register(pluginActionRoutes, { + pluginManager, + configRegistry, + config, + }); + await fastify.register(pluginSubmitRoutes, { + pluginManager, + configRegistry, + config, + }); + await fastify.register(pluginBundleRoutes, { + pluginManager, + }); + await fastify.register(pluginDataRoutes, { + pluginManager, + configRegistry, + config, + }); + await fastify.register(pluginLogRoutes, { + logRepository, + config, + }); + + // Log retention job + const retentionDays = parseInt(process.env.LOG_RETENTION_DAYS ?? "30", 10); + const runRetention = async () => { + try { + const deleted = await logRepository.deleteOlderThan(retentionDays); + if (deleted > 0) { + fastify.log.info({ deleted, retentionDays }, "Log retention cleanup"); + } + } catch (err) { + fastify.log.warn({ error: (err as Error).message }, "Log retention failed"); + } + }; + await runRetention(); + const retentionInterval = setInterval(runRetention, 6 * 60 * 60 * 1000); + + // Graceful shutdown + const shutdown = async (signal: string) => { + fastify.log.info({ signal }, "Shutting down..."); + clearInterval(retentionInterval); + await eventConsumerManager.close(); + await pluginManager.close(); + await closeDbPool(dbPool); + await fastify.close(); + process.exit(0); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + // Start server + try { + await fastify.listen({ port: config.PORT, host: "0.0.0.0" }); + const scheme = httpsOptions ? "https" : "http"; + fastify.log.info(`Plugin Host listening on ${scheme}://0.0.0.0:${config.PORT}`); + } catch (err) { + fastify.log.error(err); + await closeDbPool(dbPool); + process.exit(1); + } +} + +main(); diff --git a/plugin-host/app/src/models/app-config.test.ts b/plugin-host/app/src/models/app-config.test.ts new file mode 100644 index 0000000000..db7a870aad --- /dev/null +++ b/plugin-host/app/src/models/app-config.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {hostname} from "node:os"; +import {describe, expect, it} from "vitest"; +import {envSchema} from "./app-config"; + +describe("envSchema", () => { + it("requires ADMIN_TOKEN", () => { + expect(() => envSchema.parse({})).toThrow(); + expect(() => envSchema.parse({ ADMIN_TOKEN: "" })).toThrow(); + }); + + it("applies defaults when only ADMIN_TOKEN is supplied", () => { + const cfg = envSchema.parse({ ADMIN_TOKEN: "secret" }); + expect(cfg.PORT).toBe(8090); + expect(cfg.PLUGIN_STORAGE_DIR).toBe("./plugins"); + expect(cfg.LOG_LEVEL).toBe("info"); + // The host DB defaults to 5434, not the standard 5432. + expect(cfg.DB_PORT).toBe(5434); + expect(cfg.DB_NAME).toBe("pluginhost"); + }); + + it("defaults HOST_ID to the OS hostname", () => { + const cfg = envSchema.parse({ ADMIN_TOKEN: "secret" }); + expect(cfg.HOST_ID).toBe(hostname()); + }); + + it("honours an explicit HOST_ID", () => { + const cfg = envSchema.parse({ ADMIN_TOKEN: "secret", HOST_ID: "host-a" }); + expect(cfg.HOST_ID).toBe("host-a"); + }); + + it("coerces numeric env strings for PORT and DB_PORT", () => { + const cfg = envSchema.parse({ ADMIN_TOKEN: "secret", PORT: "9000", DB_PORT: "6000" }); + expect(cfg.PORT).toBe(9000); + expect(cfg.DB_PORT).toBe(6000); + }); + + it("rejects an out-of-enum LOG_LEVEL", () => { + expect(() => envSchema.parse({ ADMIN_TOKEN: "secret", LOG_LEVEL: "trace" })).toThrow(); + }); + + it("defaults the execution/limit knobs and coerces their env strings", () => { + const defaults = envSchema.parse({ ADMIN_TOKEN: "secret" }); + expect(defaults.WASM_TIMEOUT_MS).toBe(30_000); + expect(defaults.WASM_MAX_MEMORY_PAGES).toBe(4096); + expect(defaults.WASM_INSTANCE_IDLE_TTL_MS).toBe(10 * 60 * 1000); + expect(defaults.GZAC_API_TIMEOUT_MS).toBe(60_000); + expect(defaults.USER_TOKEN_INTROSPECTION_TIMEOUT_MS).toBe(10_000); + expect(defaults.UPLOAD_MAX_BYTES).toBe(25 * 1024 * 1024); + expect(defaults.DATA_RATE_LIMIT_PER_MINUTE).toBe(120); + expect(defaults.CONFIG_CACHE_TTL_MS).toBe(10_000); + + const cfg = envSchema.parse({ + ADMIN_TOKEN: "secret", + WASM_TIMEOUT_MS: "5000", + WASM_MAX_MEMORY_PAGES: "0", + DATA_RATE_LIMIT_PER_MINUTE: "0", + }); + expect(cfg.WASM_TIMEOUT_MS).toBe(5000); + expect(cfg.WASM_MAX_MEMORY_PAGES).toBe(0); // 0 = no memory cap + expect(cfg.DATA_RATE_LIMIT_PER_MINUTE).toBe(0); // 0 = rate limit off + }); + + it("rejects non-positive or non-numeric execution limits", () => { + expect(() => envSchema.parse({ ADMIN_TOKEN: "secret", WASM_TIMEOUT_MS: "0" })).toThrow(); + expect(() => envSchema.parse({ ADMIN_TOKEN: "secret", WASM_TIMEOUT_MS: "abc" })).toThrow(); + expect(() => envSchema.parse({ ADMIN_TOKEN: "secret", UPLOAD_MAX_BYTES: "-1" })).toThrow(); + }); + + it("leaves TLS paths undefined when not set", () => { + const cfg = envSchema.parse({ ADMIN_TOKEN: "secret" }); + expect(cfg.TLS_CERT_PATH).toBeUndefined(); + expect(cfg.TLS_KEY_PATH).toBeUndefined(); + expect(cfg.TLS_CA_PATH).toBeUndefined(); + }); +}); diff --git a/plugin-host/app/src/models/app-config.ts b/plugin-host/app/src/models/app-config.ts new file mode 100644 index 0000000000..9b3f8ada02 --- /dev/null +++ b/plugin-host/app/src/models/app-config.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { hostname } from "node:os"; +import { z } from "zod"; + +export const envSchema = z.object({ + PORT: z.coerce.number().default(8090), + ADMIN_TOKEN: z.string().min(1), + PLUGIN_STORAGE_DIR: z.string().default("./plugins"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + // Event delivery is not configured on the host: each GZAC instance pushes its own broker + // (amqpUrl/exchange) alongside every configuration, and the host opens one consumer per broker. + // + // Identity of this logical host. Used to name the per-host event queue so that, on a fanout + // exchange, every distinct host bound to the same GZAC instance receives its own copy of each + // event. Replicas of the SAME host must share one HOST_ID so they load-balance (process each + // event once) instead of each handling it. Defaults to the OS hostname. + HOST_ID: z.string().min(1).default(() => hostname()), + + // Wasm execution limits. Every plugin call is bounded by WASM_TIMEOUT_MS (Extism cancels the + // call and the route reports a HOST_ERROR); WASM_MAX_MEMORY_PAGES caps the module's linear + // memory (64 KiB per page — the default 4096 pages = 256 MiB). Set WASM_MAX_MEMORY_PAGES=0 to + // remove the cap (not recommended outside local development). + WASM_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + WASM_MAX_MEMORY_PAGES: z.coerce.number().int().min(0).default(4096), + // Idle Extism instances are closed after this long without a call (a periodic sweep frees the + // worker + memory; the next call transparently re-instantiates). 0 disables eviction. + WASM_INSTANCE_IDLE_TTL_MS: z.coerce.number().int().min(0).default(10 * 60 * 1000), + + // Upper bound on the gzac_api callback fetch — matches http_request's hard cap so a hung GZAC + // endpoint cannot pin a plugin call (and its per-plugin lock) forever. + GZAC_API_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), + + // Upper bound on the user-token introspection call the /data route makes against GZAC before + // executing Wasm. Deliberately shorter than GZAC_API_TIMEOUT_MS: introspection happens on the + // request path of a public route, so a hung GZAC should fail the request (503, fail closed) + // quickly rather than pin it for a minute. + USER_TOKEN_INTROSPECTION_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + + // Maximum accepted plugin package (.zip) upload size in bytes. The multipart parser enforces + // this before the file is buffered for the HMAC check. + UPLOAD_MAX_BYTES: z.coerce.number().int().positive().default(25 * 1024 * 1024), + + // Per-configuration rate limit for the public /plugins/:id/:version/data route (requests per + // minute per configurationId). 0 disables the limit. + DATA_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(120), + + // How long the ConfigRegistry serves configurations from its in-memory cache before re-reading + // Postgres. Writes through this host invalidate immediately; pushes handled by ANOTHER replica + // are picked up after at most this TTL. 0 disables caching. + CONFIG_CACHE_TTL_MS: z.coerce.number().int().min(0).default(10_000), + + // Database configuration + DB_HOST: z.string().default("localhost"), + DB_PORT: z.coerce.number().default(5434), + DB_NAME: z.string().default("pluginhost"), + DB_USER: z.string().default("pluginhost"), + DB_PASSWORD: z.string().default("pluginhost"), + + // Optional TLS termination. Set TLS_CERT_PATH and TLS_KEY_PATH (PEM files) together to make the + // host serve HTTPS, so the GZAC→host configuration push — which carries the broker AMQP URL, + // its credentials, and the per-config service token — is encrypted on the wire rather than only + // HMAC-authenticated. TLS_CA_PATH supplies the intermediate/CA chain when the certificate file + // is not already self-contained. Leave all three unset to serve plain HTTP (local development, + // or when TLS is terminated by a reverse proxy in front of the host). + TLS_CERT_PATH: z.string().optional(), + TLS_KEY_PATH: z.string().optional(), + TLS_CA_PATH: z.string().optional(), +}); + +export type AppConfig = z.infer; diff --git a/plugin-host/app/src/models/host-logger.ts b/plugin-host/app/src/models/host-logger.ts new file mode 100644 index 0000000000..b1a42611b7 --- /dev/null +++ b/plugin-host/app/src/models/host-logger.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Minimal logger interface compatible with both pino and Fastify's logger. + */ +export interface HostLogger { + info(obj: Record, msg?: string): void; + info(msg: string): void; + warn(obj: Record, msg?: string): void; + warn(msg: string): void; + error(obj: Record, msg?: string): void; + error(msg: string): void; + debug(obj: Record, msg?: string): void; + debug(msg: string): void; + child(bindings: Record): HostLogger; +} diff --git a/plugin-host/app/src/models/index.ts b/plugin-host/app/src/models/index.ts new file mode 100644 index 0000000000..d9f3262a0a --- /dev/null +++ b/plugin-host/app/src/models/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { AppConfig } from "./app-config.js"; +export { envSchema } from "./app-config.js"; +export type { HostLogger } from "./host-logger.js"; +export type { PluginConfiguration, EventBrokerConfig } from "./plugin-configuration.js"; +export type { PluginManifest, FrontendBundle, Endpoint } from "./plugin-manifest.js"; diff --git a/plugin-host/app/src/models/plugin-configuration.ts b/plugin-host/app/src/models/plugin-configuration.ts new file mode 100644 index 0000000000..6edd6951ef --- /dev/null +++ b/plugin-host/app/src/models/plugin-configuration.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Endpoint } from "./plugin-manifest.js"; + +/** + * Connection details for the event broker of the GZAC instance that owns a configuration. Pushed by + * GZAC alongside the configuration — the host never configures a broker itself, because a single + * host serves multiple GZAC instances, each with its own broker. The host opens one consumer per + * distinct broker and routes its events only to configurations that carry the matching broker. + */ +export interface EventBrokerConfig { + /** AMQP URL the host should connect to, e.g. `amqp://guest:guest@rabbitmq:5672`. */ + amqpUrl: string; + /** Exchange the GZAC instance's outbox publishes to (typically `valtimo-events`). */ + exchange: string; + /** Exchange type — must match the GZAC instance's declaration. */ + exchangeType: "fanout" | "topic" | "direct"; + /** + * Per-host queue declaration mode the GZAC admin chose for this host: + * - `"live"`: queue is `durable:false, autoDelete:true`. Events while the host is down are lost. + * - `"durable"`: queue is `durable:true, autoDelete:false` with `x-expires = queueTtlMs`. Events + * are retained for up to that TTL since the last consumer disconnected. + * + * Absent or unrecognised values are treated as `"live"` (older GZAC instances don't push this). + */ + queueMode?: "live" | "durable"; + /** + * Queue inactivity TTL in milliseconds. Required when `queueMode === "durable"`; ignored + * (treated as undefined) when `queueMode === "live"`. + */ + queueTtlMs?: number; +} + +export interface PluginConfiguration { + configurationId: string; + pluginId: string; + pluginVersion: string; + properties: Record; + /** + * Service token GZAC issues for this configuration. The host attaches it as a Bearer token + * on outbound `gzac_api` callbacks. + */ + serviceToken: string; + /** + * Base URL of the GZAC instance that owns this configuration. The host appends the path the + * plugin requests in `gzac_api` to this URL. + */ + gzacBaseUrl: string; + /** + * CloudEvent types the admin granted this configuration at activation. The dispatch loop only + * invokes `handle_event` for types in this set, regardless of what the manifest declares — so a + * later manifest update that adds an event type cannot silently start delivering it. Empty + * (or absent) means the plugin receives no events. + */ + eventSubscriptions: string[]; + /** + * Host capabilities the admin granted at activation (`gzac_api`, `http_request`, `kv`, `log`). + * Each host function checks this list before executing. Empty means the plugin cannot call any + * host function. + */ + grantedCapabilities?: string[]; + /** + * GZAC endpoints the admin granted at activation (Ant-style `{method, pattern}` entries — `*` + * matches one path segment, `**` any). The `gzac_api` host function refuses callbacks outside + * this list before they leave the host; GZAC's own allowlist filter remains the authoritative + * server-side gate. `undefined` means the owning GZAC instance predates endpoint pushing — + * the host then logs a warning and allows the call (backward compatibility), relying on the + * server-side filter alone. + */ + grantedEndpoints?: Endpoint[]; + /** + * Event broker of the owning GZAC instance. Absent when the instance has no broker configured — + * the configuration then receives no platform events (actions still work). + */ + eventBroker?: EventBrokerConfig; +} diff --git a/plugin-host/app/src/models/plugin-manifest.ts b/plugin-host/app/src/models/plugin-manifest.ts new file mode 100644 index 0000000000..ca2ba7a87d --- /dev/null +++ b/plugin-host/app/src/models/plugin-manifest.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { + PluginManifest, + ManifestAction, + ManifestActionProperty, + Endpoint, + FrontendBundle, +} from "@valtimo/plugin-sdk"; diff --git a/plugin-host/app/src/plugin-manager.test.ts b/plugin-host/app/src/plugin-manager.test.ts new file mode 100644 index 0000000000..63a0ffc605 --- /dev/null +++ b/plugin-host/app/src/plugin-manager.test.ts @@ -0,0 +1,264 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * L1 tests for the PluginManager's *wiring* around Extism: the execution-limit options passed to + * `createPlugin`, timeout error mapping, grant plumbing into the per-call host context, the + * unload-vs-in-flight-call lock, and idle-instance eviction. The real Wasm behaviour is covered by + * the L3 suite (`test/wasm/`), which needs the extism-js toolchain. + */ + +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {HostLogger} from "./models/index.js"; + +const createPluginMock = vi.fn(); +vi.mock("@extism/extism", () => ({ + default: (...args: unknown[]) => createPluginMock(...args), +})); + +// Import AFTER the mock so plugin-manager picks it up. +const {PluginManager} = await import("./plugin-manager.js"); + +const PLUGIN_ID = "test-plugin"; +const VERSION = "1.0.0"; + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +function fakeExtismPlugin() { + return { + call: vi.fn(async () => ({ text: () => JSON.stringify({ status: "completed" }) })), + close: vi.fn(async () => {}), + }; +} + +const configProvider = { + get: vi.fn(async () => ({ + configurationId: "cfg-1", + pluginId: PLUGIN_ID, + pluginVersion: VERSION, + properties: {}, + serviceToken: "svc", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: [], + grantedCapabilities: ["gzac_api"], + grantedEndpoints: [{ method: "GET", pattern: "/api/v1/document/*" }], + })), +}; + +const stubRepos = { kv: {} as never, log: {} as never }; + +const actionInput = { + configurationId: "cfg-1", + configuration: {}, + processInstanceId: "p", + documentId: "d", + activityId: "a", + properties: {}, + serviceToken: "svc", + gzacBaseUrl: "http://gzac:8080", +}; + +describe("PluginManager (mocked Extism)", () => { + let storageDir: string; + let manager: InstanceType; + + function makeManager(options: ConstructorParameters[5] = {}) { + return new PluginManager( + storageDir, + noopLogger(), + configProvider as never, + stubRepos.kv, + stubRepos.log, + options + ); + } + + beforeEach(() => { + createPluginMock.mockReset(); + createPluginMock.mockImplementation(async () => fakeExtismPlugin()); + configProvider.get.mockClear(); + storageDir = mkdtempSync(join(tmpdir(), "plugin-manager-test-")); + const pluginDir = join(storageDir, PLUGIN_ID, VERSION); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync( + join(pluginDir, "manifest.json"), + JSON.stringify({ + pluginId: PLUGIN_ID, + version: VERSION, + translations: { en: { name: "Test", description: "d" } }, + actions: [], + }) + ); + writeFileSync(join(pluginDir, "plugin.wasm"), Buffer.from([0x00, 0x61, 0x73, 0x6d])); + }); + + afterEach(async () => { + await manager?.close(); + rmSync(storageDir, { recursive: true, force: true }); + vi.useRealTimers(); + }); + + it("passes the configured timeout and memory cap to createPlugin", async () => { + manager = makeManager({ wasmTimeoutMs: 12_345, wasmMaxMemoryPages: 512 }); + await manager.loadPlugin(PLUGIN_ID, VERSION); + await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + + expect(createPluginMock).toHaveBeenCalledOnce(); + const options = createPluginMock.mock.calls[0][1] as Record; + expect(options.timeoutMs).toBe(12_345); + expect(options.memory).toEqual({ maxPages: 512 }); + expect(options.runInWorker).toBe(true); + }); + + it("defaults the limits and omits the memory cap when it is disabled (0)", async () => { + manager = makeManager({ wasmMaxMemoryPages: 0 }); + await manager.loadPlugin(PLUGIN_ID, VERSION); + await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + + const options = createPluginMock.mock.calls[0][1] as Record; + expect(options.timeoutMs).toBe(30_000); + expect(options.memory).toBeUndefined(); + }); + + it("maps Extism's timeout cancellation to a clear error and drops the cached instance", async () => { + manager = makeManager({ wasmTimeoutMs: 500 }); + await manager.loadPlugin(PLUGIN_ID, VERSION); + const stuck = fakeExtismPlugin(); + stuck.call.mockRejectedValueOnce(new Error("EXTISM: call canceled due to timeout")); + createPluginMock.mockResolvedValueOnce(stuck); + + await expect( + manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput) + ).rejects.toThrow(/timed out after 500ms/); + // The stale worker was discarded… + expect(stuck.close).toHaveBeenCalled(); + + // …and the next call gets a fresh instance and succeeds. + const result = await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + expect(result.status).toBe("completed"); + expect(createPluginMock).toHaveBeenCalledTimes(2); + }); + + it("threads the configuration's grants (capabilities + endpoints) into the per-call host context", async () => { + manager = makeManager(); + await manager.loadPlugin(PLUGIN_ID, VERSION); + const instance = fakeExtismPlugin(); + createPluginMock.mockResolvedValueOnce(instance); + + await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + + const hostCtx = instance.call.mock.calls[0][2] as Record; + expect(hostCtx.grantedCapabilities).toEqual(["gzac_api"]); + expect(hostCtx.grantedEndpoints).toEqual([{ method: "GET", pattern: "/api/v1/document/*" }]); + expect(hostCtx.serviceToken).toBe("svc"); + }); + + it("does not close an in-flight call's instance on unloadPlugin — it waits for the lock", async () => { + manager = makeManager(); + await manager.loadPlugin(PLUGIN_ID, VERSION); + + const instance = fakeExtismPlugin(); + let finishCall!: () => void; + let closedDuringCall = false; + instance.call.mockImplementationOnce(async () => { + await new Promise((resolve) => { + finishCall = resolve; + }); + closedDuringCall = instance.close.mock.calls.length > 0; + return { text: () => JSON.stringify({ status: "completed" }) }; + }); + createPluginMock.mockResolvedValueOnce(instance); + + const inFlight = manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + // Let the call reach the Extism instance, then race a delete against it. + await vi.waitFor(() => expect(instance.call).toHaveBeenCalled()); + const unloading = manager.unloadPlugin(PLUGIN_ID, VERSION); + + finishCall(); + await expect(inFlight).resolves.toMatchObject({ status: "completed" }); + await unloading; + + expect(closedDuringCall).toBe(false); + expect(instance.close).toHaveBeenCalled(); + }); + + describe("package content hash", () => { + it("computes a stable hash at load time and exposes it via getContentHash and listPlugins", async () => { + manager = makeManager(); + await manager.loadPlugin(PLUGIN_ID, VERSION); + + const hash = manager.getContentHash(PLUGIN_ID, VERSION); + expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(manager.listPlugins()[0]).toMatchObject({ pluginId: PLUGIN_ID, contentHash: hash }); + expect(manager.listVersions(PLUGIN_ID)[0]).toMatchObject({ contentHash: hash }); + + // Reloading unchanged bytes yields the same hash. + await manager.loadPlugin(PLUGIN_ID, VERSION); + expect(manager.getContentHash(PLUGIN_ID, VERSION)).toBe(hash); + }); + + it("changes the hash when any packaged file changes — including frontend assets", async () => { + manager = makeManager(); + await manager.loadPlugin(PLUGIN_ID, VERSION); + const original = manager.getContentHash(PLUGIN_ID, VERSION); + + const frontendDir = join(storageDir, PLUGIN_ID, VERSION, "frontend"); + mkdirSync(frontendDir, { recursive: true }); + writeFileSync(join(frontendDir, "case-tab.bundle.js"), "console.log('v2');"); + await manager.loadPlugin(PLUGIN_ID, VERSION); + + expect(manager.getContentHash(PLUGIN_ID, VERSION)).not.toBe(original); + }); + + it("hasVersion reports loaded versions and unloaded-but-on-disk versions", async () => { + manager = makeManager(); + expect(manager.hasVersion(PLUGIN_ID, VERSION)).toBe(true); // on disk, not loaded + await manager.loadPlugin(PLUGIN_ID, VERSION); + expect(manager.hasVersion(PLUGIN_ID, VERSION)).toBe(true); // loaded + expect(manager.hasVersion(PLUGIN_ID, "9.9.9")).toBe(false); + }); + }); + + it("evicts an instance that has been idle past the TTL, via the periodic sweep", async () => { + vi.useFakeTimers(); + manager = makeManager({ instanceIdleTtlMs: 1_000 }); + await manager.loadPlugin(PLUGIN_ID, VERSION); + const instance = fakeExtismPlugin(); + createPluginMock.mockResolvedValueOnce(instance); + + await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + expect(instance.close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2_500); + expect(instance.close).toHaveBeenCalled(); + + // The next call transparently re-instantiates. + await manager.callAction(PLUGIN_ID, VERSION, "echo", actionInput); + expect(createPluginMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugin-host/app/src/plugin-manager.ts b/plugin-host/app/src/plugin-manager.ts new file mode 100644 index 0000000000..3e9683f7db --- /dev/null +++ b/plugin-host/app/src/plugin-manager.ts @@ -0,0 +1,764 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {Plugin as ExtismPlugin} from "@extism/extism"; +import createPlugin from "@extism/extism"; +import {mkdir, readdir, readFile, rm, writeFile} from "node:fs/promises"; +import {join} from "node:path"; +import {existsSync} from "node:fs"; +import {createHash} from "node:crypto"; +import type {HostLogger, PluginConfiguration, PluginManifest} from "./models/index.js"; +import {createGzacApiHostFunction, type GzacApiCallContext} from "./host-functions/gzac-api.js"; +import type {KvRepository} from "./db/kv-repository.js"; +import type {LogRepository} from "./db/log-repository.js"; +import {createKvHostFunction} from "./host-functions/kv.js"; +import {createLogHostFunction} from "./host-functions/log.js"; +import {createHttpRequestHostFunction} from "./host-functions/http-request.js"; + +interface LoadedPlugin { + pluginId: string; + version: string; + manifest: PluginManifest; + /** Package content hash (see {@link computeContentHash}) — GZAC pins this at discovery. */ + contentHash: string; + wasmPath: string; + extismPlugin: ExtismPlugin | null; + /** + * Serializes access to {@link extismPlugin}. Extism instances are not reentrant — a second + * `plugin.call` (or a concurrent instance creation) while one is in flight throws "plugin is not + * reentrant". Calls chain through this promise so only one runs at a time per loaded plugin. + * Unload/remove and idle eviction chain through the same promise, so an instance is never closed + * mid-execution. + */ + lock: Promise; + /** When the instance last finished a call — drives idle eviction. */ + lastUsedAt: number; +} + +/** + * The subset of the configuration store the manager needs: per-call grant lookups. Satisfied by + * both `ConfigRegistry` (cached — what production wires in) and a bare `ConfigRepository`. + */ +export interface ConfigProvider { + get(configurationId: string): Promise; +} + +export interface PluginManagerOptions { + /** Allow plain-http targets in `http_request` (dev only). */ + allowHttp?: boolean; + /** Allow private/loopback targets in `http_request` (dev only). */ + allowPrivateNetwork?: boolean; + /** Hard wall-clock limit per Wasm call; Extism cancels the call when exceeded. */ + wasmTimeoutMs?: number; + /** Cap on the module's linear memory in 64 KiB pages; 0 disables the cap. */ + wasmMaxMemoryPages?: number; + /** Timeout for the `gzac_api` callback fetch. */ + gzacApiTimeoutMs?: number; + /** Idle Extism instances are closed after this long without a call; 0 disables eviction. */ + instanceIdleTtlMs?: number; +} + +const DEFAULT_WASM_TIMEOUT_MS = 30_000; +const DEFAULT_WASM_MAX_MEMORY_PAGES = 4096; // 64 KiB/page → 256 MiB +const DEFAULT_GZAC_API_TIMEOUT_MS = 60_000; +const DEFAULT_INSTANCE_IDLE_TTL_MS = 10 * 60 * 1000; + +/** Extism cancels a timed-out call with "EXTISM: call canceled due to timeout". */ +function isWasmTimeoutError(err: unknown): boolean { + return err instanceof Error && /canceled due to timeout/i.test(err.message); +} + +/** + * Computes the package content hash: SHA-256 over every file in the plugin version directory + * (manifest.json, plugin.wasm, the logo, frontend/**), each record bound to its relative path and + * byte length so files cannot be renamed or shuffled without changing the hash. GZAC pins this + * value at discovery and flags the definition for re-acceptance when it changes — the on-disk + * package is tamper-evident even though the host itself is only semi-trusted. + * + * Exported so the upload route can hash an extracted-but-not-yet-stored package and tell an + * identical re-upload apart from one with different content. + */ +export async function computeContentHash(pluginDir: string): Promise { + const files: string[] = []; + const walk = async (dir: string, prefix: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(join(dir, entry.name), rel); + } else if (entry.isFile()) { + files.push(rel); + } + } + }; + await walk(pluginDir, ""); + files.sort(); + + const hash = createHash("sha256"); + for (const rel of files) { + const content = await readFile(join(pluginDir, rel)); + hash.update(`${rel}\0${content.length}\0`); + hash.update(content); + } + return `sha256:${hash.digest("hex")}`; +} + +/** + * Manages the lifecycle of Wasm plugins. + * + * Composite key: pluginId@version identifies a loaded plugin. + * Multiple versions of the same plugin can coexist. + */ +export class PluginManager { + private plugins = new Map(); + private logger: HostLogger; + private storageDir: string; + private configProvider: ConfigProvider; + private kvRepository: KvRepository; + private logRepository: LogRepository; + private readonly allowHttp: boolean; + private readonly allowPrivateNetwork: boolean; + private readonly wasmTimeoutMs: number; + private readonly wasmMaxMemoryPages: number; + private readonly gzacApiTimeoutMs: number; + private readonly instanceIdleTtlMs: number; + private evictionTimer: ReturnType | null = null; + + constructor( + storageDir: string, + logger: HostLogger, + configProvider: ConfigProvider, + kvRepository: KvRepository, + logRepository: LogRepository, + options: PluginManagerOptions = {} + ) { + this.storageDir = storageDir; + this.logger = logger.child({ component: "PluginManager" }); + this.configProvider = configProvider; + this.kvRepository = kvRepository; + this.logRepository = logRepository; + this.allowHttp = options.allowHttp ?? false; + this.allowPrivateNetwork = options.allowPrivateNetwork ?? false; + this.wasmTimeoutMs = options.wasmTimeoutMs ?? DEFAULT_WASM_TIMEOUT_MS; + this.wasmMaxMemoryPages = options.wasmMaxMemoryPages ?? DEFAULT_WASM_MAX_MEMORY_PAGES; + this.gzacApiTimeoutMs = options.gzacApiTimeoutMs ?? DEFAULT_GZAC_API_TIMEOUT_MS; + this.instanceIdleTtlMs = options.instanceIdleTtlMs ?? DEFAULT_INSTANCE_IDLE_TTL_MS; + + if (this.instanceIdleTtlMs > 0) { + // Periodic sweep closing instances that have been idle longer than the TTL. Cached Extism + // instances each hold a worker thread + Wasm memory, so a burst of activity would otherwise + // pin that footprint forever. `unref()` keeps the timer from holding the process open. + const sweepEvery = Math.min(this.instanceIdleTtlMs, 60_000); + this.evictionTimer = setInterval(() => this.evictIdleInstances(), sweepEvery); + this.evictionTimer.unref?.(); + } + } + + private key(pluginId: string, version: string): string { + return `${pluginId}@${version}`; + } + + /** + * Load a plugin from its storage directory. + * Expects: {storageDir}/{pluginId}/{version}/manifest.json and plugin.wasm + */ + async loadPlugin(pluginId: string, version: string): Promise { + const pluginDir = join(this.storageDir, pluginId, version); + const manifestPath = join(pluginDir, "manifest.json"); + const wasmPath = join(pluginDir, "plugin.wasm"); + + if (!existsSync(manifestPath)) { + throw new Error(`Manifest not found: ${manifestPath}`); + } + if (!existsSync(wasmPath)) { + throw new Error(`Wasm module not found: ${wasmPath}`); + } + + const manifest: PluginManifest = JSON.parse( + await readFile(manifestPath, "utf-8") + ); + + if (manifest.pluginId !== pluginId || manifest.version !== version) { + throw new Error( + `Manifest pluginId/version mismatch: expected ${pluginId}@${version}, got ${manifest.pluginId}@${manifest.version}` + ); + } + + const contentHash = await computeContentHash(pluginDir); + + const k = this.key(pluginId, version); + + // If already loaded, unload first (hot-reload) + if (this.plugins.has(k)) { + this.logger.info({ pluginId, version }, "Hot-reloading plugin"); + await this.unloadPlugin(pluginId, version); + } + + this.plugins.set(k, { + pluginId, + version, + manifest, + contentHash, + wasmPath, + extismPlugin: null, + lock: Promise.resolve(), + lastUsedAt: Date.now(), + }); + + this.logger.info({ pluginId, version, contentHash }, "Plugin loaded"); + } + + /** + * Unload a plugin version, freeing its Wasm instance. + * + * The entry is removed from the map first (new calls fail fast with "Plugin not found"), then + * the instance is closed through the same per-plugin lock every call runs on — so an in-flight + * call finishes before its instance is closed rather than being killed mid-execution. + */ + async unloadPlugin(pluginId: string, version: string): Promise { + const k = this.key(pluginId, version); + const loaded = this.plugins.get(k); + if (!loaded) return; + + this.plugins.delete(k); + await this.runExclusive(loaded, async () => { + if (loaded.extismPlugin) { + try { + await loaded.extismPlugin.close(); + } catch { + // Ignore close errors + } + loaded.extismPlugin = null; + } + }); + + this.logger.info({ pluginId, version }, "Plugin unloaded"); + } + + /** + * Store a plugin package to disk and load it. + * If frontendDir is provided, copies the frontend directory into the plugin storage. + * If logoSourcePath is provided and exists, copies the file to the plugin storage so the host + * can serve it at GET /plugins/:id/:version/logo. + */ + async storeAndLoad( + pluginId: string, + version: string, + manifestJson: string, + wasmBuffer: Buffer, + frontendDir?: string, + logoSourcePath?: string + ): Promise { + const pluginDir = join(this.storageDir, pluginId, version); + await mkdir(pluginDir, { recursive: true }); + + await writeFile(join(pluginDir, "manifest.json"), manifestJson); + await writeFile(join(pluginDir, "plugin.wasm"), wasmBuffer); + + if (frontendDir && existsSync(frontendDir)) { + const { cp } = await import("node:fs/promises"); + const destFrontendDir = join(pluginDir, "frontend"); + await cp(frontendDir, destFrontendDir, { recursive: true }); + this.logger.info({ pluginId, version }, "Frontend assets stored"); + } + + if (logoSourcePath && existsSync(logoSourcePath)) { + const { cp } = await import("node:fs/promises"); + const logoFilename = logoSourcePath.split("/").pop()!; + await cp(logoSourcePath, join(pluginDir, logoFilename)); + this.logger.info({ pluginId, version, logo: logoFilename }, "Logo stored"); + } + + await this.loadPlugin(pluginId, version); + return JSON.parse(manifestJson); + } + + /** + * Get the storage directory path for a plugin version. + */ + getPluginDir(pluginId: string, version: string): string { + return join(this.storageDir, pluginId, version); + } + + /** + * Remove a plugin version from disk and memory. Waits for an in-flight call to finish (via + * {@link unloadPlugin}'s lock) before the instance is closed and the files are deleted. + */ + async removePlugin(pluginId: string, version: string): Promise { + await this.unloadPlugin(pluginId, version); + + const pluginDir = join(this.storageDir, pluginId, version); + if (existsSync(pluginDir)) { + await rm(pluginDir, { recursive: true }); + } + + // Clean up empty parent directory + const parentDir = join(this.storageDir, pluginId); + if (existsSync(parentDir)) { + const remaining = await readdir(parentDir); + if (remaining.length === 0) { + await rm(parentDir, { recursive: true }); + } + } + } + + /** + * Get or create the Extism plugin instance for a loaded plugin. + * + * Plugin uses WASI for stdio (console.log from QuickJS goes to stdout). + * + * `runInWorker: true` is required so that async host functions (e.g. `gzac_api`, which fetches + * from GZAC) can suspend the Wasm call until the JS promise resolves. Without this, async host + * functions only work on Node 23+ via JSPI. It is also what makes `timeoutMs` enforceable — + * Extism cancels a call that exceeds it by terminating and restarting the worker. + */ + private async getOrCreateExtismPlugin( + loaded: LoadedPlugin + ): Promise { + if (loaded.extismPlugin) { + return loaded.extismPlugin; + } + + const plugin = await createPlugin(loaded.wasmPath, { + useWasi: true, + enableWasiOutput: true, + runInWorker: true, + // Execution limits: a plugin stuck in an infinite loop is cancelled after wasmTimeoutMs + // (surfacing as a HOST_ERROR to the caller), and its linear memory cannot grow beyond + // wasmMaxMemoryPages (0 = uncapped). + timeoutMs: this.wasmTimeoutMs, + ...(this.wasmMaxMemoryPages > 0 + ? { memory: { maxPages: this.wasmMaxMemoryPages } } + : {}), + functions: { + "extism:host/user": { + gzac_api: createGzacApiHostFunction(this.logger, { + timeoutMs: this.gzacApiTimeoutMs, + }), + kv: createKvHostFunction(this.logger, this.kvRepository), + log: createLogHostFunction(this.logger, this.logRepository), + http_request: createHttpRequestHostFunction( + this.logger, + this.logRepository, + this.allowHttp, + this.allowPrivateNetwork + ), + }, + }, + }); + + loaded.extismPlugin = plugin; + return plugin; + } + + /** + * Runs `fn` with exclusive access to the loaded plugin's Extism instance. Calls are chained + * through {@link LoadedPlugin.lock} so only one is ever in flight — Extism instances are not + * reentrant, and a burst of events would otherwise call the same cached instance concurrently + * ("plugin is not reentrant"). The tail swallows the result/rejection so one failed call never + * breaks the chain for the next. + */ + private runExclusive(loaded: LoadedPlugin, fn: () => Promise): Promise { + const run = loaded.lock.then(fn, fn); + loaded.lock = run.then( + () => undefined, + () => undefined + ); + return run; + } + + /** Closes cached instances that haven't served a call for {@link instanceIdleTtlMs}. */ + private evictIdleInstances(): void { + const now = Date.now(); + for (const loaded of this.plugins.values()) { + if (!loaded.extismPlugin || now - loaded.lastUsedAt < this.instanceIdleTtlMs) continue; + // Through the lock, so an instance is never closed while a call is executing. Re-check + // idleness inside: a call may have queued between the sweep and the lock being free. + void this.runExclusive(loaded, async () => { + if (!loaded.extismPlugin || Date.now() - loaded.lastUsedAt < this.instanceIdleTtlMs) { + return; + } + try { + await loaded.extismPlugin.close(); + } catch { + // Ignore close errors + } + loaded.extismPlugin = null; + this.logger.info( + { pluginId: loaded.pluginId, version: loaded.version }, + "Evicted idle plugin instance" + ); + }); + } + } + + /** Stops the idle-eviction sweep and closes every cached instance. Call on shutdown. */ + async close(): Promise { + if (this.evictionTimer) { + clearInterval(this.evictionTimer); + this.evictionTimer = null; + } + await Promise.all( + Array.from(this.plugins.values()).map((loaded) => + this.runExclusive(loaded, async () => { + if (loaded.extismPlugin) { + try { + await loaded.extismPlugin.close(); + } catch { + // Ignore close errors + } + loaded.extismPlugin = null; + } + }) + ) + ); + } + + /** + * Resolves the grants (capabilities + gzac_api endpoint allowlist) the admin gave a + * configuration. Read per call so a re-push takes effect immediately. + */ + private async resolveGrants( + configurationId: string | undefined + ): Promise> { + if (!configurationId) { + return { grantedCapabilities: [] }; + } + const config = await this.configProvider.get(configurationId); + return { + grantedCapabilities: config?.grantedCapabilities ?? [], + grantedEndpoints: config?.grantedEndpoints, + }; + } + + /** + * Invokes one exported plugin function with exclusive access to the Extism instance and parses + * its JSON reply. All four exports (`handle_action` / `handle_event` / `handle_request` / + * `handle_submit`) funnel through here; the public wrappers only shape their input/host-context. + * + * A call cancelled by the Wasm timeout is rethrown with a clear message — the routes map any + * thrown error to a 5xx `HOST_ERROR` result — and the cached instance is dropped so the next + * call starts from a fresh one. + */ + private async callExport( + pluginId: string, + version: string, + exportName: string, + wasmInput: string, + hostCtx: GzacApiCallContext, + logContext: Record + ): Promise { + const k = this.key(pluginId, version); + const loaded = this.plugins.get(k); + + if (!loaded) { + throw new Error(`Plugin not found: ${pluginId}@${version}`); + } + + this.logger.debug({ pluginId, version, ...logContext }, `Calling ${exportName}`); + + const output = await this.runExclusive(loaded, async () => { + const plugin = await this.getOrCreateExtismPlugin(loaded); + try { + const result = await plugin.call(exportName, wasmInput, hostCtx); + if (!result) { + throw new Error(`${exportName} returned null for ${pluginId}@${version}`); + } + return JSON.parse(result.text()) as T; + } catch (err) { + if (isWasmTimeoutError(err)) { + loaded.extismPlugin = null; + void plugin.close().catch(() => {}); + throw new Error( + `Plugin execution timed out after ${this.wasmTimeoutMs}ms (${pluginId}@${version} ${exportName})` + ); + } + throw err; + } finally { + loaded.lastUsedAt = Date.now(); + } + }); + + this.logger.debug( + { pluginId, version, ...logContext, status: output.status }, + `${exportName} completed` + ); + + return output; + } + + /** + * Call the handle_action exported function on a plugin. + * + * `serviceToken` and `gzacBaseUrl` are passed via Extism's per-call host context — they are + * never serialized into the Wasm input. Host functions (e.g. `gzac_api`) read them via + * `callContext.hostContext()`. + */ + async callAction( + pluginId: string, + version: string, + actionKey: string, + input: { + configurationId: string; + configuration: Record; + processInstanceId: string; + documentId: string; + activityId: string; + properties: Record; + serviceToken: string; + gzacBaseUrl: string; + } + ): Promise<{ + status: string; + variables?: Record; + result?: unknown; + errorCode?: string; + errorMessage?: string; + }> { + const { serviceToken, gzacBaseUrl, ...wasmFields } = input; + const wasmInput = JSON.stringify({ + actionKey, + ...wasmFields, + }); + + const hostCtx: GzacApiCallContext = { + configurationId: input.configurationId, + pluginId, + pluginVersion: version, + serviceToken, + gzacBaseUrl, + ...(await this.resolveGrants(input.configurationId)), + }; + + return this.callExport(pluginId, version, "handle_action", wasmInput, hostCtx, { actionKey }); + } + + /** + * Call the handle_event exported function on a plugin. + * + * Like {@link callAction}, `serviceToken` and `gzacBaseUrl` are passed via Extism's per-call + * host context so the event handler can call back into GZAC via `gzac_api`; they are never + * serialized into the Wasm input. + */ + async callEvent( + pluginId: string, + version: string, + input: { + configurationId: string; + configuration: Record; + event: Record; + serviceToken: string; + gzacBaseUrl: string; + } + ): Promise<{ status: string; errorCode?: string; errorMessage?: string }> { + // The Wasm input is the EventInput shape: the event envelope/payload plus the configuration. + const wasmInput = JSON.stringify({ + ...input.event, + configuration: input.configuration, + }); + + const hostCtx: GzacApiCallContext = { + configurationId: input.configurationId, + pluginId, + pluginVersion: version, + serviceToken: input.serviceToken, + gzacBaseUrl: input.gzacBaseUrl, + ...(await this.resolveGrants(input.configurationId)), + }; + + const eventType = (input.event as { type?: string }).type; + return this.callExport(pluginId, version, "handle_event", wasmInput, hostCtx, { eventType }); + } + + /** + * Call the handle_request exported function on a plugin — the RPC-style data route used by the + * plugin's iframe (forwarded by the host's `/plugins/:id/:version/data` route). + * + * Like {@link callAction}, `serviceToken` and `gzacBaseUrl` (when present) are passed via Extism's + * per-call host context so a request handler *could* call back into GZAC via `gzac_api`; they are + * never serialized into the Wasm input. + */ + async callRequest( + pluginId: string, + version: string, + input: { + configurationId?: string; + configuration: Record; + method: string; + path: string; + query?: Record; + body?: unknown; + context?: Record; + serviceToken?: string; + gzacBaseUrl?: string; + userToken?: string; + } + ): Promise<{ status: number; headers?: Record; body?: unknown }> { + // serviceToken / gzacBaseUrl / userToken are host-only — destructured out so they are never + // serialized into the Wasm input the plugin sees. They reach GZAC only via the gzac_api host + // function, which reads them from the per-call host context below. + const { serviceToken, gzacBaseUrl, userToken, ...wasmFields } = input; + const wasmInput = JSON.stringify({ + ...wasmFields, + configuration: input.configuration, + }); + + const hostCtx: GzacApiCallContext = { + configurationId: input.configurationId ?? "", + pluginId, + pluginVersion: version, + serviceToken: serviceToken ?? "", + gzacBaseUrl: gzacBaseUrl ?? "", + userToken: userToken, + ...(await this.resolveGrants(input.configurationId)), + }; + + return this.callExport(pluginId, version, "handle_request", wasmInput, hostCtx, { + method: input.method, + path: input.path, + }); + } + + /** + * Call the handle_submit exported function on a plugin — the task-form submit hook (Level 1) + * GZAC invokes during submission. Like {@link callAction}, `serviceToken` and `gzacBaseUrl` are + * passed via Extism's per-call host context so the hook *could* enrich via `gzac_api` (service + * token only — no user token is forwarded on this server-to-server path); they are never + * serialized into the Wasm input. + */ + async callSubmit( + pluginId: string, + version: string, + submitKey: string, + input: { + configurationId: string; + configuration: Record; + taskId?: string; + processInstanceId?: string; + documentId?: string; + submission: Record; + serviceToken: string; + gzacBaseUrl: string; + } + ): Promise<{ + status: string; + variables?: Record; + documentContent?: Record; + errorCode?: string; + errorMessage?: string; + fieldErrors?: Record; + }> { + // Wasm input excludes serviceToken / gzacBaseUrl — they're host-only. + const { serviceToken, gzacBaseUrl, ...wasmFields } = input; + const wasmInput = JSON.stringify({ + submitKey, + ...wasmFields, + }); + + const hostCtx: GzacApiCallContext = { + configurationId: input.configurationId, + pluginId, + pluginVersion: version, + serviceToken, + gzacBaseUrl, + ...(await this.resolveGrants(input.configurationId)), + }; + + return this.callExport(pluginId, version, "handle_submit", wasmInput, hostCtx, { submitKey }); + } + + /** + * Get the manifest for a loaded plugin. + */ + getManifest(pluginId: string, version: string): PluginManifest | null { + const k = this.key(pluginId, version); + return this.plugins.get(k)?.manifest ?? null; + } + + /** + * Get the package content hash for a loaded plugin. + */ + getContentHash(pluginId: string, version: string): string | null { + const k = this.key(pluginId, version); + return this.plugins.get(k)?.contentHash ?? null; + } + + /** + * Whether this plugin version exists — loaded in memory or present on disk. Used by the upload + * route to make versions immutable: a version that ever existed cannot be silently replaced. + */ + hasVersion(pluginId: string, version: string): boolean { + if (this.plugins.has(this.key(pluginId, version))) return true; + return existsSync(join(this.storageDir, pluginId, version, "manifest.json")); + } + + /** + * List all loaded plugins. + */ + listPlugins(): Array<{ + pluginId: string; + version: string; + contentHash: string; + manifest: PluginManifest; + }> { + return Array.from(this.plugins.values()).map((p) => ({ + pluginId: p.pluginId, + version: p.version, + contentHash: p.contentHash, + manifest: p.manifest, + })); + } + + /** + * List all versions of a specific plugin. + */ + listVersions( + pluginId: string + ): Array<{ version: string; contentHash: string; manifest: PluginManifest }> { + return Array.from(this.plugins.values()) + .filter((p) => p.pluginId === pluginId) + .map((p) => ({ version: p.version, contentHash: p.contentHash, manifest: p.manifest })); + } + + /** + * Scan storage directory and load all plugins found on disk. + */ + async loadAllFromDisk(): Promise { + if (!existsSync(this.storageDir)) { + await mkdir(this.storageDir, { recursive: true }); + return; + } + + const pluginDirs = await readdir(this.storageDir); + for (const pluginId of pluginDirs) { + const pluginPath = join(this.storageDir, pluginId); + try { + const versionDirs = await readdir(pluginPath); + for (const version of versionDirs) { + try { + await this.loadPlugin(pluginId, version); + } catch (err) { + this.logger.warn( + { pluginId, version, error: (err as Error).message }, + "Failed to load plugin from disk" + ); + } + } + } catch { + // Not a directory, skip + } + } + } +} diff --git a/plugin-host/app/src/rabbitmq/event-consumer.test.ts b/plugin-host/app/src/rabbitmq/event-consumer.test.ts new file mode 100644 index 0000000000..b813e1c911 --- /dev/null +++ b/plugin-host/app/src/rabbitmq/event-consumer.test.ts @@ -0,0 +1,328 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {HostLogger, PluginConfiguration} from "../models/index.js"; +import {EventConsumerManager} from "./event-consumer"; + +// Shared capture points for the amqplib mock: every channel's consume() callback (tagged with its +// queue name) and the channel objects, so a test can drive a message into a specific broker's +// consumer and inspect the queue declaration. +const h = vi.hoisted(() => ({ + consumers: [] as Array<{ queue: string; cb: (msg: unknown) => unknown; channel: MockChannel }>, + channels: [] as MockChannel[], +})); + +interface MockChannel { + prefetch: ReturnType; + assertExchange: ReturnType; + assertQueue: ReturnType; + bindQueue: ReturnType; + consume: ReturnType; + ack: ReturnType; + nack: ReturnType; + close: ReturnType; +} + +vi.mock("amqplib", () => { + const makeChannel = (): MockChannel => { + const channel: MockChannel = { + prefetch: vi.fn(async () => {}), + assertExchange: vi.fn(async () => {}), + assertQueue: vi.fn(async (q: string) => ({ queue: q })), + bindQueue: vi.fn(async () => {}), + consume: vi.fn(async (q: string, cb: (msg: unknown) => unknown) => { + h.consumers.push({ queue: q, cb, channel }); + return { consumerTag: "tag" }; + }), + ack: vi.fn(), + nack: vi.fn(), + close: vi.fn(async () => {}), + }; + h.channels.push(channel); + return channel; + }; + const makeConnection = () => ({ + on: vi.fn(), + createChannel: vi.fn(async () => makeChannel()), + close: vi.fn(async () => {}), + }); + const connect = vi.fn(async () => makeConnection()); + return { connect, default: { connect } }; +}); + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +const BROKER_1 = { + amqpUrl: "amqp://broker-1", + exchange: "valtimo-events", + exchangeType: "fanout" as const, + queueMode: "live" as const, +}; +const BROKER_2 = { + amqpUrl: "amqp://broker-2", + exchange: "other-exchange", + exchangeType: "fanout" as const, + queueMode: "live" as const, +}; + +function config(overrides: Partial = {}): PluginConfiguration { + return { + configurationId: "cfg-A", + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: { setting: "x" }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["com.ritense.valtimo.document.created"], + eventBroker: BROKER_1, + ...overrides, + }; +} + +function cloudEvent(overrides: Record = {}) { + return { + id: "evt-1", + source: "gzac", + type: "com.ritense.valtimo.document.created", + time: "2026-07-10T12:00:00Z", + data: { + userId: "alice", + roles: ["ROLE_USER"], + resultType: "document", + resultId: "doc-99", + result: { foo: "bar" }, + }, + ...overrides, + }; +} + +function message(event: unknown) { + return { content: Buffer.from(JSON.stringify(event), "utf-8") }; +} + +function consumerFor(exchange: string) { + const c = h.consumers.find((x) => x.queue.includes(exchange)); + if (!c) throw new Error(`no consumer for exchange ${exchange}; queues: ${h.consumers.map((x) => x.queue)}`); + return c; +} + +describe("EventConsumerManager", () => { + let pluginManager: { callEvent: ReturnType }; + let configRegistry: { list: ReturnType }; + let manager: EventConsumerManager; + + function build(configs: PluginConfiguration[]) { + pluginManager = { callEvent: vi.fn(async () => ({ status: "completed" })) }; + configRegistry = { list: vi.fn(async () => configs) }; + manager = new EventConsumerManager( + pluginManager as never, + configRegistry as never, + "host-1", + noopLogger() + ); + } + + beforeEach(() => { + h.consumers.length = 0; + h.channels.length = 0; + vi.clearAllMocks(); + }); + + afterEach(async () => { + await manager?.close(); + }); + + describe("dispatch grant gate", () => { + it("invokes handle_event for a config whose granted subscriptions include the event type", async () => { + build([config()]); + await manager.sync(); + + await consumerFor("valtimo-events").cb(message(cloudEvent())); + + expect(pluginManager.callEvent).toHaveBeenCalledTimes(1); + expect(pluginManager.callEvent).toHaveBeenCalledWith( + "case-summary", + "0.1.0", + expect.objectContaining({ + configurationId: "cfg-A", + configuration: { setting: "x" }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + }) + ); + }); + + it("does NOT deliver an event type absent from the granted subscription set", async () => { + // The type is NOT in eventSubscriptions even though a plugin manifest might declare it. + build([config({ eventSubscriptions: ["com.ritense.valtimo.task.completed"] })]); + await manager.sync(); + + await consumerFor("valtimo-events").cb(message(cloudEvent())); + + expect(pluginManager.callEvent).not.toHaveBeenCalled(); + }); + + it("does NOT deliver to a config on a different broker than the consuming connection", async () => { + const onBroker1 = config({ configurationId: "on-1", eventBroker: BROKER_1 }); + const onBroker2 = config({ configurationId: "on-2", eventBroker: BROKER_2 }); + build([onBroker1, onBroker2]); + await manager.sync(); + + // Feed the event through broker-1's consumer only. + await consumerFor("valtimo-events").cb(message(cloudEvent())); + + expect(pluginManager.callEvent).toHaveBeenCalledTimes(1); + expect(pluginManager.callEvent).toHaveBeenCalledWith( + "case-summary", + "0.1.0", + expect.objectContaining({ configurationId: "on-1" }) + ); + }); + }); + + describe("event flattening", () => { + it("flattens the CloudEvent envelope + data into the EventInput the plugin receives", async () => { + build([config()]); + await manager.sync(); + + await consumerFor("valtimo-events").cb(message(cloudEvent())); + + const event = pluginManager.callEvent.mock.calls[0][2].event; + expect(event).toEqual({ + type: "com.ritense.valtimo.document.created", + id: "evt-1", + source: "gzac", + time: "2026-07-10T12:00:00Z", + userId: "alice", + roles: ["ROLE_USER"], + resultType: "document", + resultId: "doc-99", + result: { foo: "bar" }, + }); + }); + + it("ignores a CloudEvent with no type (acks without dispatching)", async () => { + build([config()]); + await manager.sync(); + + const consumer = consumerFor("valtimo-events"); + await consumer.cb(message(cloudEvent({ type: undefined }))); + + expect(pluginManager.callEvent).not.toHaveBeenCalled(); + expect(consumer.channel.ack).toHaveBeenCalledTimes(1); + expect(consumer.channel.nack).not.toHaveBeenCalled(); + }); + }); + + describe("message acknowledgement", () => { + it("acks a successfully processed message", async () => { + build([config()]); + await manager.sync(); + + const consumer = consumerFor("valtimo-events"); + await consumer.cb(message(cloudEvent())); + + expect(consumer.channel.ack).toHaveBeenCalledTimes(1); + expect(consumer.channel.nack).not.toHaveBeenCalled(); + }); + + it("still acks when a handle_event invocation throws (one failure doesn't poison the loop)", async () => { + build([config()]); + await manager.sync(); + pluginManager.callEvent.mockRejectedValueOnce(new Error("plugin blew up")); + + const consumer = consumerFor("valtimo-events"); + await consumer.cb(message(cloudEvent())); + + expect(pluginManager.callEvent).toHaveBeenCalledTimes(1); + expect(consumer.channel.ack).toHaveBeenCalledTimes(1); + expect(consumer.channel.nack).not.toHaveBeenCalled(); + }); + + it("nack-drops a malformed message without requeue", async () => { + build([config()]); + await manager.sync(); + + const consumer = consumerFor("valtimo-events"); + await consumer.cb({ content: Buffer.from("{not-json", "utf-8") }); + + expect(consumer.channel.ack).not.toHaveBeenCalled(); + expect(consumer.channel.nack).toHaveBeenCalledWith(expect.anything(), false, false); + }); + }); + + describe("queue declaration", () => { + it("declares a live-mode queue as non-durable + auto-delete with a .live suffix", async () => { + build([config({ eventBroker: BROKER_1 })]); + await manager.sync(); + + const assertQueue = consumerFor("valtimo-events").channel.assertQueue; + expect(assertQueue).toHaveBeenCalledWith( + "valtimo-external-plugins.valtimo-events.host-1.live", + { durable: false, autoDelete: true } + ); + }); + + it("declares a durable-mode queue with x-expires and a .durable suffix", async () => { + build([ + config({ + eventBroker: { + ...BROKER_1, + queueMode: "durable", + queueTtlMs: 259_200_000, + }, + }), + ]); + await manager.sync(); + + const assertQueue = consumerFor("valtimo-events").channel.assertQueue; + expect(assertQueue).toHaveBeenCalledWith( + "valtimo-external-plugins.valtimo-events.host-1.durable", + { durable: true, autoDelete: false, arguments: { "x-expires": 259_200_000 } } + ); + }); + }); + + describe("broker reconciliation", () => { + it("opens exactly one consumer per distinct broker", async () => { + build([ + config({ configurationId: "a", eventBroker: BROKER_1 }), + config({ configurationId: "b", eventBroker: BROKER_1 }), // same broker → shared consumer + config({ configurationId: "c", eventBroker: BROKER_2 }), + ]); + await manager.sync(); + + expect(h.consumers).toHaveLength(2); + }); + + it("opens no consumer for a config without a broker (actions-only)", async () => { + build([config({ eventBroker: undefined })]); + await manager.sync(); + + expect(h.consumers).toHaveLength(0); + }); + }); +}); diff --git a/plugin-host/app/src/rabbitmq/event-consumer.ts b/plugin-host/app/src/rabbitmq/event-consumer.ts new file mode 100644 index 0000000000..c4c44b026e --- /dev/null +++ b/plugin-host/app/src/rabbitmq/event-consumer.ts @@ -0,0 +1,343 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as amqp from "amqplib"; +import type { EventBrokerConfig, HostLogger } from "../models/index.js"; +import type { PluginManager } from "../plugin-manager.js"; +import type { ConfigRegistry } from "../config-registry.js"; + +/** + * CloudEvent (v1, JSON format) as published by a GZAC instance's outbox. Only the fields the host + * forwards to plugins are modelled; the embedded `data` is the outbox `CloudEventData` payload. + */ +interface CloudEventJson { + id?: string; + source?: string; + type?: string; + time?: string; + data?: { + userId?: string; + roles?: string[]; + resultType?: string; + resultId?: string; + result?: unknown; + }; +} + +/** Stable identity of a broker connection — connections are shared across configs that match it. */ +function brokerKey(b: EventBrokerConfig): string { + return `${b.amqpUrl} ${b.exchange} ${b.exchangeType}`; +} + +/** + * Per-host queue name. On a fanout exchange every distinct host binds its OWN queue and so receives + * its own copy of each event; replicas sharing a `hostId` bind the same queue and load-balance. The + * exchange is included so distinct exchanges on one broker don't collide. + * + * The mode suffix means flipping `queueMode` produces a different queue name and so never collides + * with the previous queue's `assertQueue` arguments. The orphaned LIVE queue auto-deletes on + * disconnect; an orphaned DURABLE queue lingers until its `x-expires` fires (or an operator deletes + * it from the RabbitMQ management UI). + */ +function queueName(b: EventBrokerConfig, hostId: string): string { + const mode = b.queueMode ?? "live"; + return `valtimo-external-plugins.${b.exchange}.${hostId}.${mode}`; +} + +type Router = (key: string, event: CloudEventJson) => Promise; + +/** + * Exponential backoff schedule for broker reconnects: 1s, 2s, 4s, …, capped at 30s, with 50–100 % + * jitter applied so a herd of hosts losing the same broker don't all retry on the same beat. + */ +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +function backoffDelayMs(attempt: number): number { + const exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(attempt, 5)); + return Math.floor(exp * (0.5 + Math.random() * 0.5)); +} + +/** + * A single AMQP connection to one GZAC instance's broker. Binds this host's own queue to that + * instance's events exchange and forwards every consumed CloudEvent to the manager's router, tagged + * with the broker key so the manager can route it only to that instance's configurations. + * + * Once `start()` has succeeded the consumer owns its own reconnect loop: an unexpected connection + * close schedules a backed-off reconnect and the consumer stays alive in the manager's map across + * the gap. The loop terminates only when the manager calls `close()` (broker no longer referenced, + * or host shutdown). + */ +class BrokerConsumer { + private connection: Awaited> | null = null; + private channel: amqp.Channel | null = null; + private intentionalClose = false; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempt = 0; + + constructor( + private readonly broker: EventBrokerConfig, + private readonly hostId: string, + private readonly route: Router, + private readonly log: HostLogger + ) {} + + /** Open the connection and bind the queue. Throws on initial failure so the caller can decide. */ + async start(): Promise { + await this.openConnection(); + } + + async close(): Promise { + this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + try { + await this.channel?.close(); + await this.connection?.close(); + } catch { + // Ignore close errors + } finally { + this.channel = null; + this.connection = null; + } + } + + private async openConnection(): Promise { + const connection = await amqp.connect(this.broker.amqpUrl); + // amqplib emits 'error' before 'close' on abnormal drops; treat 'close' as the single recovery + // hook and log 'error' for diagnostics so a stack-trace doesn't crash the process either. + connection.on("error", (err: Error) => { + this.log.warn( + { exchange: this.broker.exchange, error: err.message }, + "Broker connection error" + ); + }); + connection.on("close", () => { + this.connection = null; + this.channel = null; + if (this.intentionalClose) return; + this.log.warn( + { exchange: this.broker.exchange }, + "Broker connection closed; scheduling reconnect" + ); + this.scheduleReconnect(); + }); + + const channel = await connection.createChannel(); + // Backpressure: cap unacked messages so a high-volume stream (e.g. document.viewed) isn't all + // pulled into memory at once. Plugin calls are serialized per instance anyway (PluginManager). + await channel.prefetch(16); + await channel.assertExchange(this.broker.exchange, this.broker.exchangeType, { durable: true }); + const queue = queueName(this.broker, this.hostId); + const queueMode = this.broker.queueMode ?? "live"; + // LIVE: live-subscription semantics — queue evaporates with the last consumer, so events while + // the host is fully down (including the reconnect window) are not retained. + // DURABLE: queue survives host restarts. `x-expires` (queue inactivity TTL) deletes the queue + // after `queueTtlMs` of having no consumer, so a host that vanishes permanently doesn't pile up + // events forever. + const q = + queueMode === "durable" + ? await channel.assertQueue(queue, { + durable: true, + autoDelete: false, + arguments: { "x-expires": this.broker.queueTtlMs }, + }) + : await channel.assertQueue(queue, { durable: false, autoDelete: true }); + // Fanout ignores the routing key; for topic/direct an empty key binds to the default. + await channel.bindQueue(q.queue, this.broker.exchange, ""); + await channel.consume(q.queue, (msg) => this.onMessage(msg), { noAck: false }); + + this.connection = connection; + this.channel = channel; + const wasReconnect = this.reconnectAttempt > 0; + this.reconnectAttempt = 0; + this.log.info( + { + exchange: this.broker.exchange, + queue: q.queue, + mode: queueMode, + ttlMs: this.broker.queueTtlMs ?? null, + }, + wasReconnect ? "Broker consumer reconnected" : "Broker consumer started" + ); + } + + private scheduleReconnect(): void { + if (this.intentionalClose || this.reconnectTimer) return; + const delay = backoffDelayMs(this.reconnectAttempt); + this.reconnectAttempt += 1; + this.log.info( + { exchange: this.broker.exchange, attempt: this.reconnectAttempt, delayMs: delay }, + "Reconnecting broker consumer" + ); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.attemptReconnect(); + }, delay); + } + + private async attemptReconnect(): Promise { + if (this.intentionalClose) return; + try { + await this.openConnection(); + } catch (err) { + this.log.warn( + { exchange: this.broker.exchange, error: (err as Error).message }, + "Broker reconnect attempt failed" + ); + this.scheduleReconnect(); + } + } + + private async onMessage(msg: amqp.ConsumeMessage | null): Promise { + if (!msg) return; + try { + const cloudEvent = JSON.parse(msg.content.toString("utf-8")) as CloudEventJson; + await this.route(brokerKey(this.broker), cloudEvent); + this.channel?.ack(msg); + } catch (err) { + this.log.warn({ error: (err as Error).message }, "Failed to process event message; dropping"); + // Don't requeue: a malformed message would loop forever. + this.channel?.nack(msg, false, false); + } + } +} + +/** + * Owns one {@link BrokerConsumer} per distinct GZAC broker and keeps them in sync with the + * configuration registry. Brokers are learned from the configurations GZAC pushes — the host never + * configures a broker itself — so a single host serves many GZAC instances, each on its own broker. + * + * Call {@link sync} after any configuration mutation: it opens consumers for newly referenced + * brokers and closes consumers no configuration references any more. An event consumed from a broker + * is delivered only to configurations carrying that same broker whose manifest subscribes to the + * event's CloudEvent `type`. + */ +export class EventConsumerManager { + private readonly log: HostLogger; + private readonly consumers = new Map(); + private chain: Promise = Promise.resolve(); + private closing = false; + + constructor( + private readonly pluginManager: PluginManager, + private readonly configRegistry: ConfigRegistry, + private readonly hostId: string, + logger: HostLogger + ) { + this.log = logger.child({ component: "EventConsumerManager" }); + } + + /** Reconcile active broker consumers with the brokers referenced by the registry. Serialized. */ + sync(): Promise { + this.chain = this.chain + .then(() => this.reconcile()) + .catch((err) => this.log.error({ error: (err as Error).message }, "Event consumer sync failed")); + return this.chain; + } + + async close(): Promise { + this.closing = true; + await Promise.all(Array.from(this.consumers.values()).map((c) => c.close())); + this.consumers.clear(); + } + + private async reconcile(): Promise { + if (this.closing) return; + + const configs = await this.configRegistry.list(); + const desired = new Map(); + for (const cfg of configs) { + if (cfg.eventBroker?.amqpUrl) desired.set(brokerKey(cfg.eventBroker), cfg.eventBroker); + } + + for (const [key, broker] of desired) { + if (this.consumers.has(key)) continue; + const consumer = new BrokerConsumer( + broker, + this.hostId, + (k, event) => this.dispatch(k, event), + this.log + ); + try { + await consumer.start(); + this.consumers.set(key, consumer); + } catch (err) { + // Leave it out of the map so the next sync retries the initial connect. Once a consumer + // has joined the map it owns its own reconnect on subsequent drops. + this.log.error( + { exchange: broker.exchange, error: (err as Error).message }, + "Failed to start broker consumer — events from this broker will not be delivered" + ); + } + } + + for (const [key, consumer] of this.consumers) { + if (desired.has(key)) continue; + await consumer.close(); + this.consumers.delete(key); + } + } + + private async dispatch(key: string, cloudEvent: CloudEventJson): Promise { + const type = cloudEvent.type; + if (!type) return; + + const data = cloudEvent.data ?? {}; + const event = { + type, + id: cloudEvent.id ?? "", + source: cloudEvent.source ?? "", + time: cloudEvent.time, + userId: data.userId, + roles: data.roles, + resultType: data.resultType, + resultId: data.resultId, + result: data.result, + }; + + const configs = await this.configRegistry.list(); + for (const cfg of configs) { + if (!cfg.eventBroker || brokerKey(cfg.eventBroker) !== key) continue; + // Authoritative gate: only dispatch to configurations whose granted-event list (pushed by + // GZAC at activation and updated on each discovery) contains this CloudEvent type. The + // manifest's declaration is *not* consulted here — a plugin version that adds a new + // subscription type can never start receiving it without an admin re-grant. + if (!cfg.eventSubscriptions.includes(type)) continue; + + try { + await this.pluginManager.callEvent(cfg.pluginId, cfg.pluginVersion, { + configurationId: cfg.configurationId, + configuration: cfg.properties, + event, + serviceToken: cfg.serviceToken, + gzacBaseUrl: cfg.gzacBaseUrl, + }); + } catch (err) { + this.log.warn( + { + configurationId: cfg.configurationId, + pluginId: cfg.pluginId, + type, + error: (err as Error).message, + }, + "handle_event invocation failed" + ); + } + } + } +} diff --git a/plugin-host/app/src/routes/health.test.ts b/plugin-host/app/src/routes/health.test.ts new file mode 100644 index 0000000000..9111ba5204 --- /dev/null +++ b/plugin-host/app/src/routes/health.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, describe, expect, it} from "vitest"; +import type {FastifyInstance} from "fastify"; +import {buildTestApp} from "../test-support/harness"; +import {healthRoutes} from "./health"; + +describe("health route", () => { + let app: FastifyInstance; + + afterEach(async () => { + await app.close(); + }); + + it("responds 200 with status UP", async () => { + app = await buildTestApp((a) => healthRoutes(a)); + const res = await app.inject({ method: "GET", url: "/health" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "UP" }); + }); +}); diff --git a/plugin-host/app/src/routes/health.ts b/plugin-host/app/src/routes/health.ts new file mode 100644 index 0000000000..37511b515c --- /dev/null +++ b/plugin-host/app/src/routes/health.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FastifyInstance } from "fastify"; + +export async function healthRoutes(fastify: FastifyInstance): Promise { + fastify.get("/health", async () => { + return { status: "UP" }; + }); +} diff --git a/plugin-host/app/src/routes/host-configurations.test.ts b/plugin-host/app/src/routes/host-configurations.test.ts new file mode 100644 index 0000000000..8e4a1dd734 --- /dev/null +++ b/plugin-host/app/src/routes/host-configurations.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {FastifyInstance} from "fastify"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp, signHeaders, testConfig} from "../test-support/harness"; +import {resetReplayCacheForTests} from "../security/hmac-auth"; +import {hostConfigurationRoutes} from "./host-configurations"; + +describe("host-configurations routes", () => { + let app: FastifyInstance; + let configRegistry: { + set: ReturnType; + get: ReturnType; + delete: ReturnType; + list: ReturnType; + }; + let pluginManager: { + getManifest: ReturnType; + getContentHash: ReturnType; + }; + let eventConsumerManager: { sync: ReturnType }; + + beforeEach(async () => { + resetReplayCacheForTests(); + configRegistry = { + set: vi.fn(async () => {}), + get: vi.fn(async () => undefined), + delete: vi.fn(async () => true), + list: vi.fn(async () => []), + }; + pluginManager = { + getManifest: vi.fn(() => ({ pluginId: "case-summary", version: "0.1.0" })), + getContentHash: vi.fn(() => "sha256:abc123"), + }; + eventConsumerManager = { sync: vi.fn(async () => {}) }; + app = await buildTestApp((a) => + hostConfigurationRoutes(a, { + configRegistry: configRegistry as never, + pluginManager: pluginManager as never, + config: testConfig(), + eventConsumerManager: eventConsumerManager as never, + }) + ); + }); + + afterEach(async () => { + await app.close(); + }); + + function postConfig(configId: string, body: unknown, secret?: string) { + const path = `/api/host/configurations/${configId}`; + const payload = JSON.stringify(body); + return app.inject({ + method: "POST", + url: path, + headers: { "content-type": "application/json", ...signHeaders("POST", path, payload, secret) }, + payload, + }); + } + + const validBody = (overrides: Record = {}) => ({ + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: { k: "v" }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["com.ritense.valtimo.document.created"], + eventBroker: { amqpUrl: "amqp://broker", exchange: "valtimo-events" }, + ...overrides, + }); + + describe("POST (push)", () => { + it("stores a normalized configuration and syncs consumers", async () => { + const res = await postConfig("cfg-1", validBody()); + + expect(res.statusCode).toBe(201); + expect(configRegistry.set).toHaveBeenCalledWith( + "cfg-1", + expect.objectContaining({ + configurationId: "cfg-1", + pluginId: "case-summary", + pluginVersion: "0.1.0", + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["com.ritense.valtimo.document.created"], + // exchangeType + queueMode defaulted by normalizeEventBroker. + eventBroker: { + amqpUrl: "amqp://broker", + exchange: "valtimo-events", + exchangeType: "fanout", + queueMode: "live", + queueTtlMs: undefined, + }, + }) + ); + expect(eventConsumerManager.sync).toHaveBeenCalledTimes(1); + }); + + it("drops non-string entries from eventSubscriptions", async () => { + await postConfig("cfg-1", validBody({ eventSubscriptions: ["a", 123, "", "b"] })); + expect(configRegistry.set.mock.calls[0][1].eventSubscriptions).toEqual(["a", "b"]); + }); + + it("disables events when the broker has no amqpUrl", async () => { + await postConfig("cfg-1", validBody({ eventBroker: { exchange: "x" } })); + expect(configRegistry.set.mock.calls[0][1].eventBroker).toBeUndefined(); + }); + + it("clamps a durable-mode TTL below the 1h floor", async () => { + await postConfig( + "cfg-1", + validBody({ + eventBroker: { amqpUrl: "amqp://broker", queueMode: "durable", queueTtlMs: 5_000 }, + }) + ); + const stored = configRegistry.set.mock.calls[0][1].eventBroker; + expect(stored.queueMode).toBe("durable"); + expect(stored.queueTtlMs).toBe(60 * 60 * 1000); + }); + + it("accepts a push whose expectedContentHash matches the loaded package", async () => { + const res = await postConfig("cfg-1", validBody({ expectedContentHash: "sha256:abc123" })); + expect(res.statusCode).toBe(201); + expect(configRegistry.set).toHaveBeenCalled(); + }); + + it("refuses a push with 409 when the package content no longer matches the pinned hash", async () => { + pluginManager.getContentHash.mockReturnValueOnce("sha256:tampered"); + const res = await postConfig("cfg-1", validBody({ expectedContentHash: "sha256:abc123" })); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ + expectedContentHash: "sha256:abc123", + actualContentHash: "sha256:tampered", + }); + expect(configRegistry.set).not.toHaveBeenCalled(); + expect(eventConsumerManager.sync).not.toHaveBeenCalled(); + }); + + it("returns 400 when serviceToken is missing", async () => { + const res = await postConfig("cfg-1", validBody({ serviceToken: undefined })); + expect(res.statusCode).toBe(400); + expect(configRegistry.set).not.toHaveBeenCalled(); + }); + + it("returns 404 when the target plugin is not loaded", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + const res = await postConfig("cfg-1", validBody()); + expect(res.statusCode).toBe(404); + expect(configRegistry.set).not.toHaveBeenCalled(); + }); + + it("returns 401 for a body signed with the wrong secret", async () => { + const res = await postConfig("cfg-1", validBody(), "attacker-secret"); + expect(res.statusCode).toBe(401); + expect(configRegistry.set).not.toHaveBeenCalled(); + }); + + it("returns 401 when the signed body differs from the sent body (tamper)", async () => { + const path = "/api/host/configurations/cfg-1"; + const signed = JSON.stringify(validBody()); + const tampered = JSON.stringify(validBody({ serviceToken: "swapped" })); + const res = await app.inject({ + method: "POST", + url: path, + headers: { "content-type": "application/json", ...signHeaders("POST", path, signed) }, + payload: tampered, + }); + expect(res.statusCode).toBe(401); + }); + }); + + describe("PUT (update)", () => { + function putConfig(configId: string, body: unknown) { + const path = `/api/host/configurations/${configId}`; + const payload = JSON.stringify(body); + return app.inject({ + method: "PUT", + url: path, + headers: { "content-type": "application/json", ...signHeaders("PUT", path, payload) }, + payload, + }); + } + + it("retains the stored broker + subscriptions when the update omits them", async () => { + const existing = { + configurationId: "cfg-1", + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: {}, + serviceToken: "old-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["com.ritense.valtimo.document.created"], + eventBroker: { amqpUrl: "amqp://broker", exchange: "valtimo-events", exchangeType: "fanout" as const }, + }; + configRegistry.get.mockResolvedValueOnce(existing); + + const res = await putConfig("cfg-1", { properties: { changed: true } }); + + expect(res.statusCode).toBe(200); + expect(configRegistry.set.mock.calls[0][1]).toMatchObject({ + eventBroker: existing.eventBroker, + eventSubscriptions: existing.eventSubscriptions, + serviceToken: "old-token", + properties: { changed: true }, + }); + }); + + it("returns 404 when updating a configuration that does not exist", async () => { + configRegistry.get.mockResolvedValueOnce(undefined); + const res = await putConfig("missing", { properties: {} }); + expect(res.statusCode).toBe(404); + }); + }); + + describe("DELETE", () => { + function del(configId: string) { + const path = `/api/host/configurations/${configId}`; + return app.inject({ method: "DELETE", url: path, headers: signHeaders("DELETE", path) }); + } + + it("removes an existing configuration and syncs (204)", async () => { + configRegistry.delete.mockResolvedValueOnce(true); + const res = await del("cfg-1"); + expect(res.statusCode).toBe(204); + expect(eventConsumerManager.sync).toHaveBeenCalledTimes(1); + }); + + it("returns 404 for an unknown configuration", async () => { + configRegistry.delete.mockResolvedValueOnce(false); + const res = await del("missing"); + expect(res.statusCode).toBe(404); + expect(eventConsumerManager.sync).not.toHaveBeenCalled(); + }); + }); + + describe("GET (list)", () => { + it("returns the registry contents", async () => { + configRegistry.list.mockResolvedValueOnce([{ configurationId: "cfg-1" }]); + const path = "/api/host/configurations"; + const res = await app.inject({ method: "GET", url: path, headers: signHeaders("GET", path) }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([{ configurationId: "cfg-1" }]); + }); + }); +}); diff --git a/plugin-host/app/src/routes/host-configurations.ts b/plugin-host/app/src/routes/host-configurations.ts new file mode 100644 index 0000000000..4e75e76a54 --- /dev/null +++ b/plugin-host/app/src/routes/host-configurations.ts @@ -0,0 +1,291 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {FastifyInstance} from "fastify"; +import {ConfigRegistry} from "../config-registry.js"; +import {PluginManager} from "../plugin-manager.js"; +import {AppConfig} from "../config.js"; +import {EventConsumerManager} from "../rabbitmq/event-consumer.js"; +import type {Endpoint, EventBrokerConfig} from "../models/index.js"; +import {createHmacAuthHook} from "../security/hmac-auth.js"; + +const EXCHANGE_TYPES = ["fanout", "topic", "direct"] as const; +const QUEUE_MODES = ["live", "durable"] as const; +const MIN_QUEUE_TTL_MS = 60 * 60 * 1000; +const MAX_QUEUE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_QUEUE_TTL_MS = 72 * 60 * 60 * 1000; + +/** + * Normalizes the `eventBroker` field GZAC sends with a configuration. Returns `undefined` (events + * disabled for the configuration) when no `amqpUrl` is supplied; defaults the exchange/type so GZAC + * only has to send the URL for the common topology. Also normalizes the per-host queue mode and + * TTL: an unknown/absent mode defaults to `"live"`; a durable-mode TTL outside the documented + * [1h, 30d] window is clamped (defensive — GZAC validates the same bounds before pushing). + */ +function normalizeEventBroker(input: unknown): EventBrokerConfig | undefined { + if (!input || typeof input !== "object") return undefined; + const b = input as Record; + const amqpUrl = typeof b.amqpUrl === "string" ? b.amqpUrl.trim() : ""; + if (!amqpUrl) return undefined; + const exchange = + typeof b.exchange === "string" && b.exchange.length > 0 ? b.exchange : "valtimo-events"; + const typeRaw = typeof b.exchangeType === "string" ? b.exchangeType : "fanout"; + const exchangeType = (EXCHANGE_TYPES as readonly string[]).includes(typeRaw) + ? (typeRaw as EventBrokerConfig["exchangeType"]) + : "fanout"; + const modeRaw = typeof b.queueMode === "string" ? b.queueMode : "live"; + const queueMode: "live" | "durable" = (QUEUE_MODES as readonly string[]).includes(modeRaw) + ? (modeRaw as "live" | "durable") + : "live"; + let queueTtlMs: number | undefined; + if (queueMode === "durable") { + const rawTtl = typeof b.queueTtlMs === "number" ? b.queueTtlMs : DEFAULT_QUEUE_TTL_MS; + queueTtlMs = Math.min(Math.max(rawTtl, MIN_QUEUE_TTL_MS), MAX_QUEUE_TTL_MS); + } + return { amqpUrl, exchange, exchangeType, queueMode, queueTtlMs }; +} + +/** + * Normalizes a string-array field from a GZAC push body (eventSubscriptions, grantedCapabilities). + * Treats anything that isn't an array of strings as an empty list. + */ +function normalizeStringArray(input: unknown): string[] { + if (!Array.isArray(input)) return []; + return input.filter((x): x is string => typeof x === "string" && x.length > 0); +} + +/** + * Normalizes the `grantedEndpoints` list from a GZAC push body. Returns `undefined` when the push + * carries no array at all — an older GZAC instance that doesn't send granted endpoints — which the + * host treats as "no host-side allowlist" (warn + allow; GZAC still enforces server-side). A pushed + * array is filtered down to well-formed `{method, pattern}` entries; an empty result denies all. + */ +function normalizeEndpoints(input: unknown): Endpoint[] | undefined { + if (!Array.isArray(input)) return undefined; + return input + .filter( + (x): x is Endpoint => + typeof x === "object" && + x !== null && + typeof (x as Endpoint).method === "string" && + (x as Endpoint).method.length > 0 && + typeof (x as Endpoint).pattern === "string" && + (x as Endpoint).pattern.length > 0 + ) + .map((x) => ({ method: x.method, pattern: x.pattern })); +} + +/** + * Configuration push endpoints. + * + * GZAC pushes decrypted configuration here on activation. + * The host stores it in-memory and injects it into every Wasm call. + * + * Authentication: HMAC-SHA256 over `{method}\n{path}\n{timestamp}\n{bodyHash}` using the host's + * ADMIN_TOKEN as the key (same scheme as the action route). The signature binds the request body — + * which carries a freshly issued service token and broker credentials — and the ±5-minute timestamp + * window blocks replay. + */ +export async function hostConfigurationRoutes( + fastify: FastifyInstance, + opts: { + configRegistry: ConfigRegistry; + pluginManager: PluginManager; + config: AppConfig; + eventConsumerManager: EventConsumerManager; + } +): Promise { + const { configRegistry, pluginManager, config, eventConsumerManager } = opts; + + // Authenticate every configuration route by HMAC signature. Write routes opt in to raw-body + // capture (config.rawBody) so the signature binds the pushed body; GET/DELETE bind an empty body. + fastify.addHook("preHandler", createHmacAuthHook(config.ADMIN_TOKEN)); + + /** + * POST /api/host/configurations/:configId — push configuration from GZAC + */ + fastify.post<{ + Params: { configId: string }; + Body: { + pluginId: string; + pluginVersion: string; + properties: Record; + serviceToken: string; + gzacBaseUrl: string; + expectedContentHash?: unknown; + eventSubscriptions?: unknown; + grantedCapabilities?: unknown; + grantedEndpoints?: unknown; + eventBroker?: unknown; + }; + }>("/api/host/configurations/:configId", { config: { rawBody: true } }, async (request, reply) => { + const { configId } = request.params; + const { pluginId, pluginVersion, properties, serviceToken, gzacBaseUrl } = + request.body; + + if (!serviceToken || typeof serviceToken !== "string") { + reply + .code(400) + .send({ error: "Missing required field: serviceToken" }); + return; + } + if (!gzacBaseUrl || typeof gzacBaseUrl !== "string") { + reply + .code(400) + .send({ error: "Missing required field: gzacBaseUrl" }); + return; + } + + // Verify the plugin is loaded + const manifest = pluginManager.getManifest(pluginId, pluginVersion); + if (!manifest) { + reply.code(404).send({ + error: `Plugin not loaded: ${pluginId}@${pluginVersion}`, + }); + return; + } + + // GZAC pins the package content hash it discovered and sends it with every push. Refusing a + // mismatch here means a config (and its fresh service token) can never be handed to plugin + // code that differs from what the admin accepted — even in the window between GZAC's + // discovery cycle and this push. + const expectedContentHash = request.body.expectedContentHash; + if (typeof expectedContentHash === "string" && expectedContentHash.length > 0) { + const actualContentHash = pluginManager.getContentHash(pluginId, pluginVersion); + if (actualContentHash !== expectedContentHash) { + request.log.warn( + { configId, pluginId, pluginVersion, expectedContentHash, actualContentHash }, + "Configuration push refused: package content hash mismatch" + ); + reply.code(409).send({ + error: `Package content hash mismatch for ${pluginId}@${pluginVersion}`, + expectedContentHash, + actualContentHash, + }); + return; + } + } + + const eventBroker = normalizeEventBroker(request.body.eventBroker); + const eventSubscriptions = normalizeStringArray(request.body.eventSubscriptions); + const grantedCapabilities = normalizeStringArray(request.body.grantedCapabilities); + const grantedEndpoints = normalizeEndpoints(request.body.grantedEndpoints); + + await configRegistry.set(configId, { + configurationId: configId, + pluginId, + pluginVersion, + properties: properties || {}, + serviceToken, + gzacBaseUrl, + eventSubscriptions, + grantedCapabilities, + grantedEndpoints, + eventBroker, + }); + await eventConsumerManager.sync(); + + request.log.info( + { + configId, + pluginId, + pluginVersion, + gzacBaseUrl, + eventBroker: eventBroker?.exchange ?? null, + eventSubscriptionCount: eventSubscriptions.length, + }, + "Configuration pushed" + ); + reply.code(201).send({ configurationId: configId }); + }); + + /** + * PUT /api/host/configurations/:configId — update configuration + */ + fastify.put<{ + Params: { configId: string }; + Body: { + properties: Record; + serviceToken?: string; + gzacBaseUrl?: string; + eventSubscriptions?: unknown; + grantedEndpoints?: unknown; + eventBroker?: unknown; + }; + }>("/api/host/configurations/:configId", { config: { rawBody: true } }, async (request, reply) => { + const { configId } = request.params; + const existing = await configRegistry.get(configId); + + if (!existing) { + reply.code(404).send({ error: `Configuration not found: ${configId}` }); + return; + } + + // Only replace the broker when the update actually carries one; otherwise keep what's stored. + const eventBroker = + "eventBroker" in request.body + ? normalizeEventBroker(request.body.eventBroker) + : existing.eventBroker; + // Same shape for the granted event-subscription list — only replace when supplied. + const eventSubscriptions = + "eventSubscriptions" in request.body + ? normalizeStringArray(request.body.eventSubscriptions) + : existing.eventSubscriptions; + // And the granted endpoint allowlist — only replace when supplied. + const grantedEndpoints = + "grantedEndpoints" in request.body + ? normalizeEndpoints(request.body.grantedEndpoints) + : existing.grantedEndpoints; + + await configRegistry.set(configId, { + ...existing, + properties: request.body.properties || {}, + serviceToken: request.body.serviceToken ?? existing.serviceToken, + gzacBaseUrl: request.body.gzacBaseUrl ?? existing.gzacBaseUrl, + eventSubscriptions, + grantedEndpoints, + eventBroker, + }); + await eventConsumerManager.sync(); + + reply.code(200).send({ configurationId: configId }); + }); + + /** + * DELETE /api/host/configurations/:configId — remove configuration + */ + fastify.delete<{ Params: { configId: string } }>( + "/api/host/configurations/:configId", + async (request, reply) => { + const deleted = await configRegistry.delete(request.params.configId); + if (!deleted) { + reply.code(404).send({ + error: `Configuration not found: ${request.params.configId}`, + }); + return; + } + await eventConsumerManager.sync(); + reply.code(204).send(); + } + ); + + /** + * GET /api/host/configurations — list all configurations + */ + fastify.get("/api/host/configurations", async () => { + return configRegistry.list(); + }); +} diff --git a/plugin-host/app/src/routes/host-management.test.ts b/plugin-host/app/src/routes/host-management.test.ts new file mode 100644 index 0000000000..3b4c3f3fcd --- /dev/null +++ b/plugin-host/app/src/routes/host-management.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {existsSync} from "node:fs"; +import {rm} from "node:fs/promises"; +import {dirname, join} from "node:path"; +import {fileURLToPath} from "node:url"; +import type {FastifyInstance} from "fastify"; +import AdmZip from "adm-zip"; +import {afterAll, afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp, signHeaders, testConfig} from "../test-support/harness"; +import {resetReplayCacheForTests} from "../security/hmac-auth"; +import {hostManagementRoutes} from "./host-management"; + +const PLUGINS_PATH = "/api/host/plugins"; + +// The upload handler extracts into /.tmp and removes it in a `finally` that runs after the +// reply is sent. Under `inject()` the worker can exit before that async cleanup finishes, so remove +// the .tmp base here (this is the only spec that uploads). +const TMP_BASE = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".tmp"); + +function makeZip(manifest: unknown): Buffer { + const zip = new AdmZip(); + zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest))); + zip.addFile("plugin.wasm", Buffer.from([0x00, 0x61, 0x73, 0x6d])); // \0asm magic + return zip.toBuffer(); +} + +function multipartBody(boundary: string, fileBuffer: Buffer): Buffer { + const pre = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="plugin.zip"\r\n` + + `Content-Type: application/zip\r\n\r\n` + ); + const post = Buffer.from(`\r\n--${boundary}--\r\n`); + return Buffer.concat([pre, fileBuffer, post]); +} + +const validManifest = { + pluginId: "case-summary", + version: "0.1.0", + translations: { en: { name: "Case Summary", description: "desc" } }, + actions: [], +}; + +describe("host-management routes", () => { + let app: FastifyInstance; + let pluginManager: { + listPlugins: ReturnType; + listVersions: ReturnType; + getManifest: ReturnType; + getContentHash: ReturnType; + hasVersion: ReturnType; + storeAndLoad: ReturnType; + removePlugin: ReturnType; + }; + let configRegistry: { listByPlugin: ReturnType }; + + beforeEach(async () => { + resetReplayCacheForTests(); + pluginManager = { + listPlugins: vi.fn(() => [{ pluginId: "case-summary", version: "0.1.0" }]), + listVersions: vi.fn(() => [{ version: "0.1.0" }]), + getManifest: vi.fn(() => ({ pluginId: "case-summary", version: "0.1.0" })), + getContentHash: vi.fn(() => "sha256:abc123"), + hasVersion: vi.fn(() => false), + storeAndLoad: vi.fn(async () => validManifest), + removePlugin: vi.fn(async () => {}), + }; + configRegistry = { listByPlugin: vi.fn(async () => []) }; + app = await buildTestApp((a) => + hostManagementRoutes(a, { + pluginManager: pluginManager as never, + configRegistry: configRegistry as never, + config: testConfig(), + }) + ); + }); + + afterEach(async () => { + await app.close(); + }); + + afterAll(async () => { + await rm(TMP_BASE, { recursive: true, force: true }).catch(() => {}); + }); + + function uploadZip(zipBuffer: Buffer, secret?: string, query = "") { + const boundary = "----vitestboundary"; + return app.inject({ + method: "POST", + url: `${PLUGINS_PATH}${query}`, + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + // The signature binds the raw file bytes (not the multipart envelope) — see deferHmac. + // The query string is deliberately not signature-bound (hmac-auth strips it). + ...signHeaders("POST", PLUGINS_PATH, zipBuffer, secret), + }, + payload: multipartBody(boundary, zipBuffer), + }); + } + + describe("GET list", () => { + it("lists all loaded plugins", async () => { + const res = await app.inject({ method: "GET", url: PLUGINS_PATH, headers: signHeaders("GET", PLUGINS_PATH) }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([{ pluginId: "case-summary", version: "0.1.0" }]); + }); + + it("lists versions of a single plugin", async () => { + const path = `${PLUGINS_PATH}/case-summary`; + const res = await app.inject({ method: "GET", url: path, headers: signHeaders("GET", path) }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([{ version: "0.1.0" }]); + }); + + it("rejects an unsigned list request with 401", async () => { + const res = await app.inject({ method: "GET", url: PLUGINS_PATH }); + expect(res.statusCode).toBe(401); + }); + }); + + describe("POST upload", () => { + it("stores and loads a valid package (file-byte-bound HMAC) → 201", async () => { + const res = await uploadZip(makeZip(validManifest)); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ + pluginId: "case-summary", + version: "0.1.0", + contentHash: "sha256:abc123", + }); + expect(pluginManager.storeAndLoad).toHaveBeenCalledWith( + "case-summary", + "0.1.0", + expect.any(String), + expect.any(Buffer), + expect.any(String), + undefined // no logo declared + ); + }); + + it("refuses to replace an existing version with 409 carrying both content hashes", async () => { + pluginManager.hasVersion.mockReturnValueOnce(true); + const res = await uploadZip(makeZip(validManifest)); + expect(res.statusCode).toBe(409); + const body = res.json() as Record; + expect(body).toMatchObject({ + code: "PLUGIN_VERSION_EXISTS", + error: "Plugin version already exists: case-summary@0.1.0", + currentContentHash: "sha256:abc123", // the loaded package's hash (mocked) + }); + // The would-be hash of the uploaded package, so callers can tell an identical re-upload + // apart from different content. + expect(body.uploadedContentHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(pluginManager.storeAndLoad).not.toHaveBeenCalled(); + }); + + it("replaces an existing version when the caller explicitly overwrites", async () => { + pluginManager.hasVersion.mockReturnValueOnce(true); + const res = await uploadZip(makeZip(validManifest), undefined, "?overwrite=true"); + expect(res.statusCode).toBe(201); + expect(pluginManager.storeAndLoad).toHaveBeenCalledWith( + "case-summary", + "0.1.0", + expect.any(String), + expect.any(Buffer), + expect.any(String), + undefined + ); + }); + + it("rejects an invalid manifest with 400 and validation details", async () => { + const res = await uploadZip(makeZip({ pluginId: "x", version: "1.0.0" })); // no translations + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: "Invalid plugin manifest" }); + expect((res.json() as { details: string[] }).details.length).toBeGreaterThan(0); + expect(pluginManager.storeAndLoad).not.toHaveBeenCalled(); + }); + + it("rejects an upload whose file bytes are not correctly signed (401)", async () => { + const res = await uploadZip(makeZip(validManifest), "attacker-secret"); + expect(res.statusCode).toBe(401); + expect(pluginManager.storeAndLoad).not.toHaveBeenCalled(); + }); + + it("rejects a package with a zip-slip entry (../ traversal) with 400 and never loads it", async () => { + // adm-zip's *writer* sanitizes entry names, so craft the hostile name by byte-patching the + // finished archive (same length; filename bytes are not CRC-protected) — exactly what an + // attacker's own zip tool would produce and what adm-zip's *reader* preserves verbatim. + const zip = new AdmZip(); + zip.addFile("manifest.json", Buffer.from(JSON.stringify(validManifest))); + zip.addFile("plugin.wasm", Buffer.from([0x00, 0x61, 0x73, 0x6d])); + zip.addFile("AA/evil.txt", Buffer.from("escaped the extraction dir")); + let raw = zip.toBuffer(); + raw = Buffer.from(raw.toString("latin1").replaceAll("AA/evil.txt", "../evil.txt"), "latin1"); + expect(new AdmZip(raw).getEntries().map((e) => e.entryName)).toContain("../evil.txt"); + const res = await uploadZip(raw); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: "Invalid plugin package" }); + expect(pluginManager.storeAndLoad).not.toHaveBeenCalled(); + // Nothing may have been written outside the temp extraction dir. + const escaped = join(TMP_BASE, "evil.txt"); + expect(existsSync(escaped)).toBe(false); + }); + + it("returns 400 when no file part is present", async () => { + const boundary = "----vitestboundary"; + const body = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="notafile"\r\n\r\nvalue\r\n--${boundary}--\r\n` + ); + const res = await app.inject({ + method: "POST", + url: PLUGINS_PATH, + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + payload: body, + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe("DELETE plugin", () => { + function del(pluginId: string, version: string) { + const path = `${PLUGINS_PATH}/${pluginId}/${version}`; + return app.inject({ method: "DELETE", url: path, headers: signHeaders("DELETE", path) }); + } + + it("removes a plugin with no active configurations → 204", async () => { + configRegistry.listByPlugin.mockResolvedValueOnce([]); + const res = await del("case-summary", "0.1.0"); + expect(res.statusCode).toBe(204); + expect(pluginManager.removePlugin).toHaveBeenCalledWith("case-summary", "0.1.0"); + }); + + it("refuses deletion with 409 and the blocking configurationIds", async () => { + configRegistry.listByPlugin.mockResolvedValueOnce([ + { configurationId: "c1" }, + { configurationId: "c2" }, + ]); + const res = await del("case-summary", "0.1.0"); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ configurationIds: ["c1", "c2"] }); + expect(pluginManager.removePlugin).not.toHaveBeenCalled(); + }); + + it("returns 404 for an unknown plugin version", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + const res = await del("case-summary", "9.9.9"); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/plugin-host/app/src/routes/host-management.ts b/plugin-host/app/src/routes/host-management.ts new file mode 100644 index 0000000000..0fd7b46c9c --- /dev/null +++ b/plugin-host/app/src/routes/host-management.ts @@ -0,0 +1,269 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FastifyInstance } from "fastify"; +import { computeContentHash, PluginManager } from "../plugin-manager.js"; +import { ConfigRegistry } from "../config-registry.js"; +import { AppConfig } from "../config.js"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, dirname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import AdmZip from "adm-zip"; +import { validatePluginManifest } from "@valtimo/plugin-sdk/manifest-validation"; +import { createHmacAuthHook, verifyDeferredHmac } from "../security/hmac-auth.js"; + +/** Raised for a malicious/malformed plugin package — mapped to a 400, not a 500. */ +export class InvalidPluginPackageError extends Error {} + +/** + * Extracts a plugin package entry-by-entry, defending against zip-slip: every entry's resolved + * destination must stay inside `extractDir` (a crafted `../`, absolute, or drive-letter entry name + * rejects the whole package). Additionally only the files a plugin package may legitimately carry + * are extracted — root-level files (manifest.json, plugin.wasm, the logo) and `frontend/**` — so a + * hostile zip cannot plant anything else even inside the temp dir. + */ +async function safeExtractPluginZip(zip: AdmZip, extractDir: string): Promise { + const root = resolve(extractDir); + for (const entry of zip.getEntries()) { + if (entry.isDirectory) continue; + const name = entry.entryName; + const destination = resolve(root, name); + if (destination !== root && !destination.startsWith(root + sep)) { + throw new InvalidPluginPackageError( + `Zip entry escapes the extraction directory: ${name}` + ); + } + // Allowlist: root-level files or frontend assets only. + const isRootFile = !name.includes("/") && !name.includes("\\"); + const isFrontendAsset = name.startsWith("frontend/"); + if (!isRootFile && !isFrontendAsset) { + continue; + } + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, entry.getData()); + } +} + +/** + * Admin-authenticated plugin management routes. + * + * All routes under /api/host/plugins are HMAC-signed (same scheme as the action route): the + * signature is computed over `{method}\n{path}\n{timestamp}\n{bodyHash}` with the host's ADMIN_TOKEN + * as the key, and the ±5-minute timestamp window blocks replay. GET/DELETE bind an empty body; the + * multipart upload binds the uploaded file bytes (verified inside its handler — see below). + */ +export async function hostManagementRoutes( + fastify: FastifyInstance, + opts: { pluginManager: PluginManager; configRegistry: ConfigRegistry; config: AppConfig } +): Promise { + const { pluginManager, configRegistry, config } = opts; + + // Authenticate every management route by HMAC signature. The upload route opts out (deferHmac) + // and verifies itself once the uploaded file has been read, since it binds the file bytes. + fastify.addHook("preHandler", createHmacAuthHook(config.ADMIN_TOKEN)); + + /** + * GET /api/host/plugins — list all loaded plugins (all versions) + */ + fastify.get("/api/host/plugins", async () => { + return pluginManager.listPlugins(); + }); + + /** + * GET /api/host/plugins/:pluginId — list all versions of a plugin + */ + fastify.get<{ Params: { pluginId: string } }>( + "/api/host/plugins/:pluginId", + async (request) => { + return pluginManager.listVersions(request.params.pluginId); + } + ); + + /** + * POST /api/host/plugins — upload plugin package (.zip) + * + * The .zip must contain manifest.json and plugin.wasm at the root level. + * pluginId and version are extracted from the manifest. + * + * An upload whose pluginId@version already exists is refused with 409 unless the caller sends + * `?overwrite=true` — GZAC only does so after an admin explicitly confirmed the overwrite and + * re-reviewed the package's requested permissions, so a version is never replaced silently. + * + * HMAC body binding: the signature covers the uploaded file bytes (the .zip), not the multipart + * envelope — the backend signs the raw file bytes it sends, which the host reproduces below from + * the parsed file stream. `deferHmac` skips the shared raw-body hook so verification can run once + * the file has been read. + */ + fastify.post("/api/host/plugins", { config: { deferHmac: true } }, async (request, reply) => { + const data = await request.file(); + if (!data) { + reply.code(400).send({ error: "No file uploaded" }); + return; + } + + // Write uploaded file to temp directory inside plugin-host/app/.tmp/ + const appRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + const tmpBase = join(appRoot, ".tmp"); + await mkdir(tmpBase, { recursive: true }); + const tempDir = await mkdtemp(join(tmpBase, "plugin-upload-")); + + try { + // Collect the stream into a buffer + const chunks: Buffer[] = []; + for await (const chunk of data.file) { + chunks.push(chunk); + } + const zipBuffer = Buffer.concat(chunks); + + // The multipart parser truncates a stream that exceeds its size limit rather than erroring — + // reject explicitly so an oversized upload can't slip through as a corrupt zip. + if (data.file.truncated) { + reply.code(413).send({ + error: `Plugin package exceeds the maximum upload size of ${config.UPLOAD_MAX_BYTES} bytes`, + }); + return; + } + + // Authenticate: the HMAC signature binds these exact file bytes (see deferHmac note above). + if (!verifyDeferredHmac(request, reply, config.ADMIN_TOKEN, zipBuffer)) { + return; + } + + // Extract zip — per entry, with zip-slip protection (see safeExtractPluginZip). + const extractDir = join(tempDir, "extracted"); + const zip = new AdmZip(zipBuffer); + await safeExtractPluginZip(zip, extractDir); + + // Read manifest + const manifestPath = join(extractDir, "manifest.json"); + const manifestJson = await readFile(manifestPath, "utf-8"); + const manifest = JSON.parse(manifestJson); + + const validationErrors = validatePluginManifest(manifest); + if (validationErrors.length > 0) { + reply + .code(400) + .send({ error: "Invalid plugin manifest", details: validationErrors }); + return; + } + + // A version is never replaced *silently*: re-uploading an existing pluginId@version without + // the explicit overwrite flag is refused, because different code would hot-reload under an + // already-accepted identity (a time-of-check/time-of-use gap). GZAC sends `?overwrite=true` + // only after an admin confirmed the overwrite and re-reviewed the package's requested + // permissions. The 409 carries both content hashes so the caller can tell an identical + // re-upload (nothing to do) apart from genuinely different content (review required). + const overwrite = (request.query as {overwrite?: string}).overwrite === "true"; + const versionExists = pluginManager.hasVersion(manifest.pluginId, manifest.version); + if (versionExists && !overwrite) { + reply.code(409).send({ + // Machine-readable so GZAC's upload UI can branch without string-matching English text. + code: "PLUGIN_VERSION_EXISTS", + error: `Plugin version already exists: ${manifest.pluginId}@${manifest.version}`, + message: + "This version already exists on the host. Overwriting requires explicit admin confirmation.", + currentContentHash: pluginManager.getContentHash(manifest.pluginId, manifest.version), + uploadedContentHash: await computeContentHash(extractDir), + }); + return; + } + if (versionExists) { + request.log.warn( + { pluginId: manifest.pluginId, version: manifest.version }, + "Overwriting existing plugin version (admin-confirmed)" + ); + } + + // Read wasm + const wasmPath = join(extractDir, "plugin.wasm"); + const wasmBuffer = await readFile(wasmPath); + + // Check for frontend directory + const frontendDir = join(extractDir, "frontend"); + + // Pack tool writes the logo filename onto the manifest; pass the source file through so the + // plugin manager can persist it alongside manifest.json and plugin.wasm. + const logoPath = manifest.logo ? join(extractDir, manifest.logo) : undefined; + + // Store and load (includes frontend assets and optional logo if present) + const result = await pluginManager.storeAndLoad( + manifest.pluginId, + manifest.version, + manifestJson, + wasmBuffer, + frontendDir, + logoPath + ); + + reply.code(201).send({ + pluginId: manifest.pluginId, + version: manifest.version, + contentHash: pluginManager.getContentHash(manifest.pluginId, manifest.version), + manifest: result, + }); + } catch (err) { + request.log.error( + { error: (err as Error).message }, + "Plugin upload failed" + ); + if (err instanceof InvalidPluginPackageError) { + reply.code(400).send({ + error: "Invalid plugin package", + message: err.message, + }); + return; + } + reply.code(500).send({ + error: "Plugin upload failed", + message: (err as Error).message, + }); + } finally { + // Cleanup temp directory + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } + }); + + /** + * DELETE /api/host/plugins/:pluginId/:version — unload and remove + * + * Refuses deletion if active configurations reference this plugin version. + */ + fastify.delete<{ Params: { pluginId: string; version: string } }>( + "/api/host/plugins/:pluginId/:version", + async (request, reply) => { + const { pluginId, version } = request.params; + + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + // Check for active configurations referencing this plugin version + const activeConfigs = await configRegistry.listByPlugin(pluginId, version); + if (activeConfigs.length > 0) { + reply.code(409).send({ + error: `Cannot delete plugin: ${activeConfigs.length} active configuration(s) reference ${pluginId}@${version}`, + configurationIds: activeConfigs.map((c) => c.configurationId), + }); + return; + } + + await pluginManager.removePlugin(pluginId, version); + reply.code(204).send(); + } + ); +} diff --git a/plugin-host/app/src/routes/plugin-actions.test.ts b/plugin-host/app/src/routes/plugin-actions.test.ts new file mode 100644 index 0000000000..54e74f9b43 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-actions.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {FastifyInstance} from "fastify"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp, signHeaders, testConfig} from "../test-support/harness"; +import {resetReplayCacheForTests} from "../security/hmac-auth"; +import {pluginActionRoutes} from "./plugin-actions"; + +const PLUGIN = "case-summary"; +const VERSION = "0.1.0"; +const ACTION = "my-action"; + +describe("plugin-actions routes", () => { + let app: FastifyInstance; + let pluginManager: { getManifest: ReturnType; callAction: ReturnType }; + let configRegistry: { get: ReturnType }; + + const storedConfig = () => ({ + configurationId: "cfg-1", + pluginId: PLUGIN, + pluginVersion: VERSION, + properties: { setting: "x" }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: [], + }); + + beforeEach(async () => { + resetReplayCacheForTests(); + pluginManager = { + getManifest: vi.fn(() => ({ actions: [{ key: ACTION }] })), + callAction: vi.fn(async () => ({ status: "completed", variables: { done: true } })), + }; + configRegistry = { get: vi.fn(async () => storedConfig()) }; + app = await buildTestApp((a) => + pluginActionRoutes(a, { + pluginManager: pluginManager as never, + configRegistry: configRegistry as never, + config: testConfig(), + }) + ); + }); + + afterEach(async () => { + await app.close(); + }); + + function invokeAction(body: unknown, secret?: string) { + const path = `/plugins/${PLUGIN}/${VERSION}/actions/${ACTION}`; + const payload = JSON.stringify(body); + return app.inject({ + method: "POST", + url: path, + headers: { "content-type": "application/json", ...signHeaders("POST", path, payload, secret) }, + payload, + }); + } + + const actionBody = (overrides: Record = {}) => ({ + configurationId: "cfg-1", + processInstanceId: "pi-1", + documentId: "doc-1", + activityId: "act-1", + properties: { a: 1 }, + ...overrides, + }); + + it("invokes the action and returns 200 with the variables", async () => { + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "completed", variables: { done: true } }); + expect(pluginManager.callAction).toHaveBeenCalledWith( + PLUGIN, + VERSION, + ACTION, + expect.objectContaining({ + configurationId: "cfg-1", + configuration: { setting: "x" }, + processInstanceId: "pi-1", + documentId: "doc-1", + activityId: "act-1", + properties: { a: 1 }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + }) + ); + }); + + it("maps a plugin-level error result to 422 (catchable as a BPMN error)", async () => { + pluginManager.callAction.mockResolvedValueOnce({ + status: "error", + errorCode: "BOOM", + errorMessage: "nope", + }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(422); + expect(res.json()).toMatchObject({ status: "error", errorCode: "BOOM" }); + }); + + it("returns 500 RESULT_CONTRACT_VIOLATION when a declared output key is absent from the result", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary", "title"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ + status: "completed", + result: { summary: "a summary" }, + }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ status: "error", errorCode: "RESULT_CONTRACT_VIOLATION" }); + expect(res.json().errorMessage).toContain("title"); + }); + + it("returns 500 RESULT_CONTRACT_VIOLATION when outputs are declared but there is no result object", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ status: "completed", variables: { done: true } }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ errorCode: "RESULT_CONTRACT_VIOLATION" }); + }); + + it("accepts declared output keys returned as null", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary", "title"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ + status: "completed", + result: { summary: null, title: null }, + }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ result: { summary: null, title: null } }); + }); + + it("returns 500 when callAction throws (host/infrastructure error)", async () => { + pluginManager.callAction.mockRejectedValueOnce(new Error("wasm crash")); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ status: "error", errorCode: "HOST_ERROR" }); + }); + + it("returns 404 when the plugin is not loaded", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + expect((await invokeAction(actionBody())).statusCode).toBe(404); + }); + + it("returns 404 when the action key is unknown", async () => { + pluginManager.getManifest.mockReturnValueOnce({ actions: [{ key: "other" }] }); + expect((await invokeAction(actionBody())).statusCode).toBe(404); + }); + + it("returns 400 when configurationId is missing", async () => { + const res = await invokeAction(actionBody({ configurationId: "" })); + expect(res.statusCode).toBe(400); + expect(pluginManager.callAction).not.toHaveBeenCalled(); + }); + + it("returns 404 when the configuration is not in the registry", async () => { + configRegistry.get.mockResolvedValueOnce(undefined); + expect((await invokeAction(actionBody())).statusCode).toBe(404); + }); + + it("returns 400 when the configuration targets a different plugin/version", async () => { + configRegistry.get.mockResolvedValueOnce({ ...storedConfig(), pluginVersion: "9.9.9" }); + expect((await invokeAction(actionBody())).statusCode).toBe(400); + }); + + it("returns 500 when the configuration is missing callback context", async () => { + configRegistry.get.mockResolvedValueOnce({ ...storedConfig(), serviceToken: "", gzacBaseUrl: "" }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ errorCode: "MISSING_CALLBACK_CONTEXT" }); + }); + + it("returns 401 for an unsigned action call", async () => { + const path = `/plugins/${PLUGIN}/${VERSION}/actions/${ACTION}`; + const res = await app.inject({ + method: "POST", + url: path, + headers: { "content-type": "application/json" }, + payload: JSON.stringify(actionBody()), + }); + expect(res.statusCode).toBe(401); + expect(pluginManager.callAction).not.toHaveBeenCalled(); + }); + + describe("GET plugin-manifest (public)", () => { + it("serves the manifest with a permissive CORS header", async () => { + const res = await app.inject({ method: "GET", url: `/plugins/${PLUGIN}/${VERSION}/plugin-manifest` }); + expect(res.statusCode).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); + + it("returns 404 when the plugin is not loaded", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + const res = await app.inject({ method: "GET", url: `/plugins/${PLUGIN}/${VERSION}/plugin-manifest` }); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/plugin-host/app/src/routes/plugin-actions.ts b/plugin-host/app/src/routes/plugin-actions.ts new file mode 100644 index 0000000000..6954c8106e --- /dev/null +++ b/plugin-host/app/src/routes/plugin-actions.ts @@ -0,0 +1,242 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {FastifyInstance} from "fastify"; +import {PluginManager} from "../plugin-manager.js"; +import {ConfigRegistry} from "../config-registry.js"; +import type {AppConfig} from "../config.js"; +import {createHmacAuthHook} from "../security/hmac-auth.js"; + +/** + * Plugin action execution endpoint. + * + * GZAC calls this when a process link fires on a service task. + * The host looks up the configuration from its in-memory registry + * (pushed by GZAC on activation/startup), injects decrypted properties + * into the Wasm function input, and returns variables to the process. + * + * Authentication: HMAC-SHA256 signature over `{method}\n{path}\n{timestamp}\n{bodyHash}` + * using the shared secret (ADMIN_TOKEN). This ensures requests originate from a GZAC + * instance that knows the host's secret. + */ +export async function pluginActionRoutes( + fastify: FastifyInstance, + opts: { pluginManager: PluginManager; configRegistry: ConfigRegistry; config: AppConfig } +): Promise { + const { pluginManager, configRegistry, config } = opts; + + /** + * POST /plugins/:pluginId/:version/actions/:actionKey + * + * Body: { + * configurationId: string, + * processInstanceId: string, + * documentId: string, + * activityId: string, + * properties: Record + * } + * + * GZAC pushes configurations to the host on activation/startup via the + * configuration push API. At action time, only the configurationId is + * sent. The host looks up decrypted properties from its in-memory registry. + * + * Authentication: HMAC-SHA256 signature in X-Valtimo-Signature header. + */ + fastify.post<{ + Params: { + pluginId: string; + version: string; + actionKey: string; + }; + Body: { + configurationId: string; + processInstanceId: string; + documentId: string; + activityId: string; + properties: Record; + }; + }>( + "/plugins/:pluginId/:version/actions/:actionKey", + { + // Capture raw body for HMAC verification while still parsing JSON + config: { + rawBody: true, + }, + preHandler: createHmacAuthHook(config.ADMIN_TOKEN), + }, + async (request, reply) => { + const { pluginId, version, actionKey } = request.params; + const { + configurationId, + processInstanceId, + documentId, + activityId, + properties, + } = request.body; + + // Verify the plugin is loaded + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ + error: `Plugin not found: ${pluginId}@${version}`, + }); + return; + } + + // Verify the action exists + const actionDef = manifest.actions.find((a) => a.key === actionKey); + if (!actionDef) { + reply.code(404).send({ + error: `Action '${actionKey}' not found on plugin ${pluginId}@${version}`, + }); + return; + } + + // Look up configuration from registry (pushed by GZAC on activation/startup) + if (!configurationId) { + reply.code(400).send({ + error: "Missing required field: configurationId", + }); + return; + } + + const pluginConfig = await configRegistry.get(configurationId); + if (!pluginConfig) { + reply.code(404).send({ + error: `Configuration not found: ${configurationId}. GZAC may need to re-sync configurations.`, + }); + return; + } + + if ( + pluginConfig.pluginId !== pluginId || + pluginConfig.pluginVersion !== version + ) { + reply.code(400).send({ + error: `Configuration ${configurationId} targets ${pluginConfig.pluginId}@${pluginConfig.pluginVersion}, not ${pluginId}@${version}`, + }); + return; + } + + if (!pluginConfig.serviceToken || !pluginConfig.gzacBaseUrl) { + reply.code(500).send({ + status: "error", + errorCode: "MISSING_CALLBACK_CONTEXT", + errorMessage: `Configuration ${configurationId} is missing serviceToken or gzacBaseUrl. GZAC must re-push the configuration before this plugin can call back.`, + }); + return; + } + + const configuration = pluginConfig.properties; + + try { + const result = await pluginManager.callAction( + pluginId, + version, + actionKey, + { + configurationId, + configuration, + processInstanceId: processInstanceId || "", + documentId: documentId || "", + activityId: activityId || "", + properties: properties || {}, + serviceToken: pluginConfig.serviceToken, + gzacBaseUrl: pluginConfig.gzacBaseUrl, + } + ); + + if (result.status === "error") { + // 4xx for plugin-level errors (catchable by BPMN error events) + reply.code(422).send(result); + return; + } + + // Enforce the manifest's result contract: every key the action declares under `outputs` + // must be present on the result object. JSON null is a valid value — only an absent key + // is a violation (typically a value dropped during serialization, e.g. `undefined` in a + // JS plugin), which GZAC would otherwise skip silently when applying result mappings. + const declaredOutputs = actionDef.outputs ?? []; + if (declaredOutputs.length > 0) { + const resultValue = result.result; + const resultObject = + resultValue && typeof resultValue === "object" && !Array.isArray(resultValue) + ? (resultValue as Record) + : undefined; + const missing = resultObject + ? declaredOutputs.filter((key) => !(key in resultObject)) + : declaredOutputs; + if (missing.length > 0) { + request.log.error( + { pluginId, version, actionKey, declaredOutputs, missing }, + "Action result violates the manifest outputs contract" + ); + reply.code(500).send({ + status: "error", + errorCode: "RESULT_CONTRACT_VIOLATION", + errorMessage: + `Action '${actionKey}' declares outputs [${declaredOutputs.join(", ")}] in its ` + + `manifest, but its result is missing: [${missing.join(", ")}]. Every declared ` + + `output must be returned; returning null for a key is allowed.`, + }); + return; + } + } + + reply.code(200).send(result); + } catch (err) { + request.log.error( + { + pluginId, + version, + actionKey, + error: (err as Error).message, + }, + "Action execution failed" + ); + // 5xx for host/infrastructure errors (creates Operaton incident) + reply.code(500).send({ + status: "error", + errorCode: "HOST_ERROR", + errorMessage: (err as Error).message, + }); + } + } + ); + + /** + * GET /plugins/:pluginId/:version/plugin-manifest + */ + fastify.get<{ Params: { pluginId: string; version: string } }>( + "/plugins/:pluginId/:version/plugin-manifest", + async (request, reply) => { + const { pluginId, version } = request.params; + // The frontend SDK fetches the manifest (for translations) from inside the plugin iframe, + // which runs at an opaque origin — so this read is cross-origin. Served public like bundles. + reply.header("Access-Control-Allow-Origin", "*"); + const manifest = pluginManager.getManifest(pluginId, version); + + if (!manifest) { + reply + .code(404) + .send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + return manifest; + } + ); +} diff --git a/plugin-host/app/src/routes/plugin-bundles.test.ts b/plugin-host/app/src/routes/plugin-bundles.test.ts new file mode 100644 index 0000000000..55fb884ee4 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-bundles.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import type {FastifyInstance} from "fastify"; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp} from "../test-support/harness"; +import {pluginBundleRoutes} from "./plugin-bundles"; + +const PLUGIN = "case-summary"; +const VERSION = "0.1.0"; + +describe("plugin-bundles routes", () => { + let pluginDir: string; + let app: FastifyInstance; + let pluginManager: { getManifest: ReturnType; getPluginDir: ReturnType }; + + beforeAll(() => { + pluginDir = mkdtempSync(join(tmpdir(), "plugin-bundles-")); + mkdirSync(join(pluginDir, "frontend"), { recursive: true }); + writeFileSync(join(pluginDir, "frontend", "case-tab.bundle.js"), "console.log('bundle');"); + writeFileSync(join(pluginDir, "logo.svg"), ""); + writeFileSync(join(pluginDir, "secret.txt"), "top secret"); // outside frontend/, for traversal test + }); + + afterAll(() => { + rmSync(pluginDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + pluginManager = { + getManifest: vi.fn(() => ({ logo: "logo.svg" })), + getPluginDir: vi.fn(() => pluginDir), + }; + app = await buildTestApp((a) => pluginBundleRoutes(a, { pluginManager: pluginManager as never })); + }); + + afterEach(async () => { + await app.close(); + }); + + describe("bundles/*", () => { + it("serves a frontend file with its content-type and permissive CORS", async () => { + const res = await app.inject({ + method: "GET", + url: `/plugins/${PLUGIN}/${VERSION}/bundles/case-tab.bundle.js`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/javascript"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + expect(res.body).toContain("console.log('bundle')"); + }); + + it("serves every bundle with a strict anti-exfiltration CSP", async () => { + const res = await app.inject({ + method: "GET", + url: `/plugins/${PLUGIN}/${VERSION}/bundles/case-tab.bundle.js`, + }); + const csp = res.headers["content-security-policy"] as string; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("connect-src 'self'"); + expect(csp).toContain("form-action 'self'"); + expect(csp).toContain("sandbox allow-scripts allow-forms"); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + }); + + it("blocks a path-traversal attempt with 403", async () => { + // %2e%2e%2f decodes to ../ — an attempt to escape the frontend/ directory to reach secret.txt. + const res = await app.inject({ + method: "GET", + url: `/plugins/${PLUGIN}/${VERSION}/bundles/%2e%2e%2fsecret.txt`, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 404 for a file that does not exist", async () => { + const res = await app.inject({ + method: "GET", + url: `/plugins/${PLUGIN}/${VERSION}/bundles/missing.js`, + }); + expect(res.statusCode).toBe(404); + }); + + it("returns 404 when the plugin is not loaded", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + const res = await app.inject({ + method: "GET", + url: `/plugins/${PLUGIN}/${VERSION}/bundles/case-tab.bundle.js`, + }); + expect(res.statusCode).toBe(404); + }); + }); + + describe("logo", () => { + it("serves the declared logo with the right content-type", async () => { + const res = await app.inject({ method: "GET", url: `/plugins/${PLUGIN}/${VERSION}/logo` }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("image/svg+xml"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + // Plugin-authored content: same CSP as the bundles (an SVG can carry script). + expect(res.headers["content-security-policy"]).toContain("script-src 'self'"); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); + + it("returns 404 when the manifest declares no logo", async () => { + pluginManager.getManifest.mockReturnValueOnce({}); + const res = await app.inject({ method: "GET", url: `/plugins/${PLUGIN}/${VERSION}/logo` }); + expect(res.statusCode).toBe(404); + }); + + it("returns 404 when the declared logo is missing on disk", async () => { + pluginManager.getManifest.mockReturnValueOnce({ logo: "ghost.svg" }); + const res = await app.inject({ method: "GET", url: `/plugins/${PLUGIN}/${VERSION}/logo` }); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/plugin-host/app/src/routes/plugin-bundles.ts b/plugin-host/app/src/routes/plugin-bundles.ts new file mode 100644 index 0000000000..c6d37b695f --- /dev/null +++ b/plugin-host/app/src/routes/plugin-bundles.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FastifyInstance } from "fastify"; +import { PluginManager } from "../plugin-manager.js"; +import { join, resolve, extname } from "node:path"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; + +const MIME_TYPES: Record = { + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +/** + * CSP for everything served out of a plugin package. The iframe sandbox already stops privilege + * escalation (opaque origin — no GZAC session or token); this policy closes the *exfiltration* + * channels a hostile bundle would otherwise have: `connect-src 'self'` kills fetch/XHR/beacon to + * third parties, `script-src 'self'` kills remote script loading, `img-src`/`font-src` kill + * pixel-beacon exfil, and `form-action 'self'` kills native form posts to external endpoints. An + * honest plugin loses nothing — all of its GZAC traffic flows through the parent-proxy postMessage + * transport, and its own assets all live under the same bundle path. + * + * The `sandbox` directive mirrors the embedding iframe's `sandbox="allow-scripts allow-forms"` + * attribute so a bundle opened directly in a top-level tab is *also* confined to an opaque origin + * instead of running same-origin with the host. + */ +const BUNDLE_CSP = [ + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "media-src 'self'", + "form-action 'self'", + "base-uri 'none'", + "object-src 'none'", + "sandbox allow-scripts allow-forms", +].join("; "); + +/** Shared hardening headers for plugin-authored content (bundles and the logo). */ +function pluginContentHeaders(reply: import("fastify").FastifyReply, contentType: string) { + return reply + .header("Content-Type", contentType) + .header("Cache-Control", "public, max-age=3600") + .header("Access-Control-Allow-Origin", "*") + .header("Content-Security-Policy", BUNDLE_CSP) + .header("X-Content-Type-Options", "nosniff") + .header("Referrer-Policy", "no-referrer"); +} + +/** + * Public routes serving frontend bundles from plugin packages. + * + * GET /plugins/:pluginId/:version/bundles/* — serves static files from the + * plugin's frontend/ directory. No authentication — these are public assets + * loaded in iframes. Every response carries the strict {@link BUNDLE_CSP}. + */ +export async function pluginBundleRoutes( + fastify: FastifyInstance, + opts: { pluginManager: PluginManager } +): Promise { + const { pluginManager } = opts; + + fastify.get<{ Params: { pluginId: string; version: string; "*": string } }>( + "/plugins/:pluginId/:version/bundles/*", + async (request, reply) => { + const { pluginId, version } = request.params; + const filePath = request.params["*"]; + + if (!filePath) { + reply.code(400).send({ error: "No file path specified" }); + return; + } + + // Verify plugin exists + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + // Resolve full path and prevent directory traversal + const pluginDir = pluginManager.getPluginDir(pluginId, version); + const frontendDir = join(pluginDir, "frontend"); + const fullPath = resolve(frontendDir, filePath); + + if (!fullPath.startsWith(resolve(frontendDir))) { + reply.code(403).send({ error: "Path traversal not allowed" }); + return; + } + + if (!existsSync(fullPath)) { + reply.code(404).send({ error: `File not found: ${filePath}` }); + return; + } + + const ext = extname(fullPath); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + const content = await readFile(fullPath); + + pluginContentHeaders(reply, contentType).send(content); + } + ); + + /** + * GET /plugins/:pluginId/:version/logo — serve the plugin logo + * + * The manifest's `logo` field (set by the pack tool when it detects a logo file at the plugin + * root) names the file. 404 if no logo was shipped with the package. + */ + fastify.get<{ Params: { pluginId: string; version: string } }>( + "/plugins/:pluginId/:version/logo", + async (request, reply) => { + const { pluginId, version } = request.params; + + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + if (!manifest.logo) { + reply.code(404).send({ error: "No logo declared in manifest" }); + return; + } + + const pluginDir = pluginManager.getPluginDir(pluginId, version); + const logoPath = resolve(pluginDir, manifest.logo); + if (!logoPath.startsWith(resolve(pluginDir)) || !existsSync(logoPath)) { + reply.code(404).send({ error: "Logo file missing on disk" }); + return; + } + + const ext = extname(logoPath); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + const content = await readFile(logoPath); + // Same policy as the bundles: a logo is plugin-authored content too (an SVG can carry + // script, which the CSP neutralises when the file is opened directly). + pluginContentHeaders(reply, contentType).send(content); + } + ); +} diff --git a/plugin-host/app/src/routes/plugin-data.test.ts b/plugin-host/app/src/routes/plugin-data.test.ts new file mode 100644 index 0000000000..3b7c1ada2e --- /dev/null +++ b/plugin-host/app/src/routes/plugin-data.test.ts @@ -0,0 +1,322 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {FastifyInstance} from "fastify"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp, testConfig} from "../test-support/harness"; +import {UserTokenIntrospector} from "../security/user-token-introspection"; +import {pluginDataRoutes} from "./plugin-data"; + +const PLUGIN = "case-summary"; +const VERSION = "0.1.0"; +const DATA_URL = `/plugins/${PLUGIN}/${VERSION}/data`; + +describe("plugin-data route", () => { + let app: FastifyInstance; + let pluginManager: { getManifest: ReturnType; callRequest: ReturnType }; + let configRegistry: { get: ReturnType }; + let introspector: { introspect: ReturnType }; + + async function buildApp(rateLimitPerMinute = 120): Promise { + app = await buildTestApp((a) => + pluginDataRoutes(a, { + pluginManager: pluginManager as never, + configRegistry: configRegistry as never, + config: testConfig({ DATA_RATE_LIMIT_PER_MINUTE: rateLimitPerMinute }), + userTokenIntrospector: introspector as unknown as UserTokenIntrospector, + }) + ); + } + + beforeEach(async () => { + pluginManager = { + getManifest: vi.fn(() => ({})), + callRequest: vi.fn(async () => ({ status: 200, body: { ok: true } })), + }; + configRegistry = { + get: vi.fn(async () => ({ + pluginId: PLUGIN, + pluginVersion: VERSION, + properties: { p: 1 }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + grantedCapabilities: ["gzac_api", "frontend_data"], + })), + }; + introspector = { + introspect: vi.fn(async () => ({ kind: "valid", configurationId: "cfg-1" })), + }; + await buildApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + it("answers the CORS preflight with 204 and permissive headers", async () => { + const res = await app.inject({ method: "OPTIONS", url: DATA_URL }); + expect(res.statusCode).toBe(204); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + expect(res.headers["access-control-allow-methods"]).toContain("POST"); + }); + + it("executes for a configuration granted the frontend_data capability", async () => { + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/summary", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); + + it("looks up the config and forwards its token context + the user token to the plugin", async () => { + await app.inject({ + method: "POST", + url: DATA_URL, + payload: { + configurationId: "cfg-1", + method: "POST", + path: "/summary", + query: { docId: "42" }, + body: { q: 1 }, + context: { documentId: "doc-1" }, + userToken: "user-tok", + }, + }); + + expect(pluginManager.callRequest).toHaveBeenCalledWith( + PLUGIN, + VERSION, + expect.objectContaining({ + configurationId: "cfg-1", + configuration: { p: 1 }, + method: "POST", + path: "/summary", + query: { docId: "42" }, + body: { q: 1 }, + context: { documentId: "doc-1" }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + // Introspected against GZAC before the call, then forwarded so the handler can use + // gzacApi.asUser (GZAC re-verifies it on every as:"user" callback). + userToken: "user-tok", + }) + ); + }); + + it("returns 400 when configurationId is absent (Wasm never runs)", async () => { + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { method: "GET", path: "/summary", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(400); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 400 when userToken is absent (Wasm never runs, no introspection)", async () => { + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/summary" }, + }); + expect(res.statusCode).toBe(400); + expect(introspector.introspect).not.toHaveBeenCalled(); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 401 when GZAC rejects the user token (Wasm never runs)", async () => { + introspector.introspect.mockResolvedValueOnce({ kind: "invalid" }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "forged" }, + }); + expect(res.statusCode).toBe(401); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 403 when the token is bound to a different configuration (Wasm never runs)", async () => { + introspector.introspect.mockResolvedValueOnce({ kind: "valid", configurationId: "cfg-OTHER" }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(403); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 503 when GZAC is unreachable — fail closed, Wasm never runs", async () => { + introspector.introspect.mockResolvedValueOnce({ kind: "unavailable" }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(503); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("introspects against the configuration's own GZAC base URL", async () => { + await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(introspector.introspect).toHaveBeenCalledWith("http://gzac:8080", "user-tok"); + }); + + it("returns 403 for an unknown configuration (Wasm never runs)", async () => { + configRegistry.get.mockResolvedValueOnce(undefined); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "ghost", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(403); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 403 when the configuration lacks the frontend_data capability", async () => { + configRegistry.get.mockResolvedValueOnce({ + pluginId: PLUGIN, + pluginVersion: VERSION, + properties: {}, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + grantedCapabilities: ["gzac_api"], + }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(403); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("returns 403 when the configuration targets a different plugin version", async () => { + configRegistry.get.mockResolvedValueOnce({ + pluginId: PLUGIN, + pluginVersion: "9.9.9", + properties: {}, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + grantedCapabilities: ["frontend_data"], + }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(403); + expect(pluginManager.callRequest).not.toHaveBeenCalled(); + }); + + it("rate-limits per configuration with 429 once the per-minute budget is spent", async () => { + await app.close(); + await buildApp(2); + const post = () => + app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect((await post()).statusCode).toBe(200); + expect((await post()).statusCode).toBe(200); + expect((await post()).statusCode).toBe(429); + }); + + it("returns 404 when the plugin is not loaded", async () => { + pluginManager.getManifest.mockReturnValueOnce(null); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(404); + }); + + it("returns 400 when method or path is missing", async () => { + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("forwards the status and headers the plugin returns", async () => { + pluginManager.callRequest.mockResolvedValueOnce({ + status: 201, + headers: { "x-custom": "yes" }, + body: { created: true }, + }); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "POST", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(201); + expect(res.headers["x-custom"]).toBe("yes"); + }); + + it("serves a second call from the introspection cache — one GZAC round-trip", async () => { + // Real introspector, stubbed network: proves the route + cache wiring end-to-end. + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + subject: "john@example.com", + configurationId: "cfg-1", + expiresAt: new Date(Date.now() + 900_000).toISOString(), + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + try { + await app.close(); + introspector = new UserTokenIntrospector() as never; + await buildApp(); + + const post = () => + app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect((await post()).statusCode).toBe(200); + expect((await post()).statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("returns 500 when the plugin request handler throws", async () => { + pluginManager.callRequest.mockRejectedValueOnce(new Error("handler exploded")); + const res = await app.inject({ + method: "POST", + url: DATA_URL, + payload: { configurationId: "cfg-1", method: "GET", path: "/x", userToken: "user-tok" }, + }); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/plugin-host/app/src/routes/plugin-data.ts b/plugin-host/app/src/routes/plugin-data.ts new file mode 100644 index 0000000000..6413e40e39 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-data.ts @@ -0,0 +1,221 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {FastifyInstance} from "fastify"; +import {PluginManager} from "../plugin-manager.js"; +import {ConfigRegistry} from "../config-registry.js"; +import {UserTokenIntrospector} from "../security/user-token-introspection.js"; +import type {AppConfig} from "../config.js"; + +/** + * Plugin-served data route. + * + * `POST /plugins/:pluginId/:version/data` invokes the plugin's `handle_request` Wasm export and + * returns the JSON it produces. It is the RPC-style counterpart of the action route, used by a + * plugin's own iframe (through the Angular parent-proxy) to fetch data the plugin serves itself. + * + * Served cross-origin to the GZAC frontend (`Access-Control-Allow-Origin: *`), like the bundle + * routes. + * + * Security: the route carries no HMAC (the caller is a browser, not GZAC). Executing plugin Wasm + * is gated on a chain of checks — all must pass, in order, or the Wasm never runs: + * + * 1. the request names a `configurationId` whose pushed configuration exists, targets this plugin + * version, and was granted the `frontend_data` capability by an admin — otherwise 403; + * 2. a per-configuration in-memory rate limit (`DATA_RATE_LIMIT_PER_MINUTE`) bounds abuse of the + * public endpoint — 429 once the budget is spent; + * 3. the request carries a GZAC-minted downscoped `userToken` (400 when absent), which the host + * validates by remote introspection against the configuration's GZAC (the HS256 signing key + * never leaves GZAC). GZAC rejecting the token → 401; the token bound to a DIFFERENT + * configuration than the request names → 403; GZAC unreachable → 503 (fail closed). Positive + * verdicts are cached briefly (see `UserTokenIntrospector`) so steady-state calls cost no + * GZAC round-trip. + * + * Plugins must still treat `handle_request` input as untrusted: the token proves the caller is an + * authenticated GZAC user of this configuration, not that any particular field is truthful. + */ +export async function pluginDataRoutes( + fastify: FastifyInstance, + opts: { + pluginManager: PluginManager; + configRegistry: ConfigRegistry; + config: AppConfig; + /** Injectable for tests; defaults to a real introspector bounded by the configured timeout. */ + userTokenIntrospector?: UserTokenIntrospector; + } +): Promise { + const { pluginManager, configRegistry, config } = opts; + const userTokenIntrospector = + opts.userTokenIntrospector ?? + new UserTokenIntrospector({ timeoutMs: config.USER_TOKEN_INTROSPECTION_TIMEOUT_MS }); + + // Fixed-window request counter per configurationId. In-memory (per replica) — good enough to + // stop a single host being hammered; 0 disables. + const rateLimitPerMinute = config.DATA_RATE_LIMIT_PER_MINUTE ?? 0; + const windows = new Map(); + const isRateLimited = (configurationId: string): boolean => { + if (rateLimitPerMinute <= 0) return false; + const now = Date.now(); + const window = windows.get(configurationId); + if (!window || now - window.windowStart >= 60_000) { + windows.set(configurationId, { windowStart: now, count: 1 }); + return false; + } + window.count += 1; + return window.count > rateLimitPerMinute; + }; + + // CORS preflight for the cross-origin POST from the opaque-origin iframe / GZAC frontend. + fastify.options<{ Params: { pluginId: string; version: string } }>( + "/plugins/:pluginId/:version/data", + async (_request, reply) => { + reply + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "POST, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type") + .code(204) + .send(); + } + ); + + fastify.post<{ + Params: { pluginId: string; version: string }; + Body: { + configurationId?: string; + method: string; + path: string; + query?: Record; + body?: unknown; + context?: Record; + /** + * Downscoped user token forwarded from the tab. REQUIRED: the host introspects it against + * GZAC before executing any Wasm, so only authenticated GZAC users of the named configuration + * can drive this public route. It also lets a `handle_request` handler call back into GZAC + * *as the user* (`gzacApi.asUser`, PBAC ∩ allowlist). + */ + userToken?: string; + }; + }>( + "/plugins/:pluginId/:version/data", + async (request, reply) => { + const { pluginId, version } = request.params; + const { configurationId, method, path, query, body, context, userToken } = + request.body ?? ({} as never); + + reply.header("Access-Control-Allow-Origin", "*"); + + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + if (!method || !path) { + reply.code(400).send({ error: "Missing required fields: method and path" }); + return; + } + + // Capability gate: this public route only executes Wasm for a configuration an admin + // explicitly granted the `frontend_data` capability. + if (!configurationId) { + reply.code(400).send({ error: "Missing required field: configurationId" }); + return; + } + + if (!userToken) { + reply.code(400).send({ error: "Missing required field: userToken" }); + return; + } + const pluginConfig = await configRegistry.get(configurationId); + if ( + !pluginConfig || + pluginConfig.pluginId !== pluginId || + pluginConfig.pluginVersion !== version || + !pluginConfig.grantedCapabilities?.includes("frontend_data") + ) { + // One message for "unknown config" / "wrong plugin" / "capability not granted" so the + // public endpoint doesn't leak which configurations exist. + reply.code(403).send({ + error: `Configuration '${configurationId}' does not exist for ${pluginId}@${version} or was not granted the 'frontend_data' capability`, + }); + return; + } + + if (isRateLimited(configurationId)) { + reply.code(429).send({ error: "Rate limit exceeded for this configuration" }); + return; + } + + // Token gate: the host cannot verify the HS256 user token locally, so it asks the + // configuration's GZAC. Fail closed — no verdict, no Wasm. + const introspection = await userTokenIntrospector.introspect( + pluginConfig.gzacBaseUrl, + userToken + ); + if (introspection.kind === "unavailable") { + reply.code(503).send({ error: "User token validation is currently unavailable" }); + return; + } + if (introspection.kind === "invalid") { + reply.code(401).send({ error: "Invalid or expired user token" }); + return; + } + if (introspection.configurationId !== configurationId) { + // Same non-leaky style as the capability gate: don't reveal which configuration the + // token IS bound to. + reply.code(403).send({ + error: `User token is not valid for configuration '${configurationId}'`, + }); + return; + } + + const configuration = pluginConfig.properties; + const serviceToken = pluginConfig.serviceToken; + const gzacBaseUrl = pluginConfig.gzacBaseUrl; + + try { + const result = await pluginManager.callRequest(pluginId, version, { + configurationId, + configuration, + method, + path, + query, + body, + context, + serviceToken, + gzacBaseUrl, + // Introspected above (valid + bound to this configuration). GZAC additionally verifies + // it server-side on every gzac_api `as:"user"` callback — the introspection gates Wasm + // execution; the callback check remains the authority on each individual GZAC call. + userToken, + }); + + if (result.headers) { + for (const [name, value] of Object.entries(result.headers)) { + reply.header(name, value); + } + } + reply.code(result.status ?? 200).send(result.body ?? null); + } catch (err) { + request.log.error( + { pluginId, version, path, error: (err as Error).message }, + "Plugin data request failed" + ); + reply.code(500).send({ error: (err as Error).message }); + } + } + ); +} diff --git a/plugin-host/app/src/routes/plugin-logs.test.ts b/plugin-host/app/src/routes/plugin-logs.test.ts new file mode 100644 index 0000000000..a78e5647e4 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-logs.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {FastifyInstance} from "fastify"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {buildTestApp, signHeaders, testConfig} from "../test-support/harness"; +import {pluginLogRoutes} from "./plugin-logs"; + +const LOGS_PATH = "/api/host/configurations/cfg-1/logs"; + +describe("plugin-logs route", () => { + let app: FastifyInstance; + let logRepository: { query: ReturnType }; + + beforeEach(async () => { + logRepository = { + query: vi.fn(async (_configId: string, params: { page: number; size: number }) => ({ + content: [], + page: params.page, + size: params.size, + totalElements: 0, + })), + }; + app = await buildTestApp(async (a) => { + await a.register(pluginLogRoutes, { + logRepository: logRepository as never, + config: testConfig(), + }); + }); + }); + + afterEach(async () => { + await app.close(); + }); + + function get(query: string) { + return app.inject({ + method: "GET", + url: `${LOGS_PATH}${query}`, + headers: signHeaders("GET", LOGS_PATH), + }); + } + + it("rejects an unsigned request with 401", async () => { + const res = await app.inject({ method: "GET", url: LOGS_PATH }); + expect(res.statusCode).toBe(401); + }); + + it("passes coerced paging through and echoes it in the response", async () => { + const res = await get("?page=2&size=50"); + expect(res.statusCode).toBe(200); + expect(logRepository.query).toHaveBeenCalledWith("cfg-1", expect.objectContaining({ page: 2, size: 50 })); + expect(res.json()).toMatchObject({ page: 2, size: 50 }); + }); + + it("falls back to defaults for non-numeric page/size instead of passing NaN to SQL", async () => { + const res = await get("?page=abc&size=%20"); + expect(res.statusCode).toBe(200); + expect(logRepository.query).toHaveBeenCalledWith("cfg-1", expect.objectContaining({ page: 0, size: 25 })); + expect(res.json()).toMatchObject({ page: 0, size: 25 }); + }); + + it("clamps negative and oversized values", async () => { + await get("?page=-5&size=10000"); + expect(logRepository.query).toHaveBeenCalledWith("cfg-1", expect.objectContaining({ page: 0, size: 100 })); + }); +}); diff --git a/plugin-host/app/src/routes/plugin-logs.ts b/plugin-host/app/src/routes/plugin-logs.ts new file mode 100644 index 0000000000..763e228c87 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-logs.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FastifyPluginCallback } from "fastify"; +import type { LogRepository } from "../db/log-repository.js"; +import type { AppConfig } from "../models/index.js"; +import { createHmacAuthHook } from "../security/hmac-auth.js"; + +interface PluginLogRoutesOptions { + logRepository: LogRepository; + config: AppConfig; +} + +export const pluginLogRoutes: FastifyPluginCallback = ( + fastify, + { logRepository, config }, + done +) => { + const hmacAuth = createHmacAuthHook(config.ADMIN_TOKEN); + + fastify.get<{ + Params: { configId: string }; + Querystring: { page?: string; size?: string; level?: string; source?: string }; + }>( + "/api/host/configurations/:configId/logs", + { preHandler: hmacAuth }, + async (request, reply) => { + const { configId } = request.params; + // Defensive coercion: parseInt on garbage yields NaN, which would flow into the SQL + // LIMIT/OFFSET. Fall back to defaults and clamp; the response echoes the coerced values. + const rawPage = Number.parseInt(request.query.page ?? "", 10); + const rawSize = Number.parseInt(request.query.size ?? "", 10); + const page = Number.isNaN(rawPage) ? 0 : Math.max(0, rawPage); + const size = Number.isNaN(rawSize) ? 25 : Math.max(1, Math.min(rawSize, 100)); + const level = request.query.level || undefined; + const source = request.query.source || undefined; + + const result = await logRepository.query(configId, { page, size, level, source }); + return reply.code(200).send({ ...result, page, size }); + } + ); + + done(); +}; diff --git a/plugin-host/app/src/routes/plugin-submit.ts b/plugin-host/app/src/routes/plugin-submit.ts new file mode 100644 index 0000000000..95405ec330 --- /dev/null +++ b/plugin-host/app/src/routes/plugin-submit.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {FastifyInstance} from "fastify"; +import {PluginManager} from "../plugin-manager.js"; +import {ConfigRegistry} from "../config-registry.js"; +import type {AppConfig} from "../config.js"; +import {createHmacAuthHook} from "../security/hmac-auth.js"; + +/** + * Plugin task-form submit endpoint (Level 1). + * + * GZAC calls this during a task-form submission when the bundle declares `submitHandler: true`. + * The host looks up the configuration from its in-memory registry (pushed by GZAC), injects + * decrypted properties + the service token into the Wasm function input, and returns the hook's + * result to GZAC. GZAC — not the plugin — then completes the task. + * + * Authentication: identical to the action route — HMAC-SHA256 over + * `{method}\n{path}\n{timestamp}\n{bodyHash}` using the shared secret (ADMIN_TOKEN). + */ +export async function pluginSubmitRoutes( + fastify: FastifyInstance, + opts: { pluginManager: PluginManager; configRegistry: ConfigRegistry; config: AppConfig } +): Promise { + const { pluginManager, configRegistry, config } = opts; + + /** + * POST /plugins/:pluginId/:version/submit/:submitKey + * + * Body: { + * configurationId: string, + * taskId?: string, + * processInstanceId?: string, + * documentId?: string, + * submission: Record + * } + */ + fastify.post<{ + Params: { pluginId: string; version: string; submitKey: string }; + Body: { + configurationId: string; + taskId?: string; + processInstanceId?: string; + documentId?: string; + submission: Record; + }; + }>( + "/plugins/:pluginId/:version/submit/:submitKey", + { + config: { rawBody: true }, + preHandler: createHmacAuthHook(config.ADMIN_TOKEN), + }, + async (request, reply) => { + const { pluginId, version, submitKey } = request.params; + const { configurationId, taskId, processInstanceId, documentId, submission } = request.body; + + const manifest = pluginManager.getManifest(pluginId, version); + if (!manifest) { + reply.code(404).send({ error: `Plugin not found: ${pluginId}@${version}` }); + return; + } + + if (!configurationId) { + reply.code(400).send({ error: "Missing required field: configurationId" }); + return; + } + + const pluginConfig = await configRegistry.get(configurationId); + if (!pluginConfig) { + reply.code(404).send({ + error: `Configuration not found: ${configurationId}. GZAC may need to re-sync configurations.`, + }); + return; + } + + if (pluginConfig.pluginId !== pluginId || pluginConfig.pluginVersion !== version) { + reply.code(400).send({ + error: `Configuration ${configurationId} targets ${pluginConfig.pluginId}@${pluginConfig.pluginVersion}, not ${pluginId}@${version}`, + }); + return; + } + + if (!pluginConfig.serviceToken || !pluginConfig.gzacBaseUrl) { + reply.code(500).send({ + status: "error", + errorCode: "MISSING_CALLBACK_CONTEXT", + errorMessage: `Configuration ${configurationId} is missing serviceToken or gzacBaseUrl. GZAC must re-push the configuration before this plugin can call back.`, + }); + return; + } + + try { + const result = await pluginManager.callSubmit(pluginId, version, submitKey, { + configurationId, + configuration: pluginConfig.properties, + taskId, + processInstanceId, + documentId, + submission: submission || {}, + serviceToken: pluginConfig.serviceToken, + gzacBaseUrl: pluginConfig.gzacBaseUrl, + }); + + if (result.status === "error") { + // 422 for plugin-level rejections (validation) — GZAC surfaces errors on the form. + reply.code(422).send(result); + return; + } + + reply.code(200).send(result); + } catch (err) { + request.log.error( + { pluginId, version, submitKey, error: (err as Error).message }, + "Submit execution failed" + ); + reply.code(500).send({ + status: "error", + errorCode: "HOST_ERROR", + errorMessage: (err as Error).message, + }); + } + } + ); +} diff --git a/plugin-host/app/src/security/endpoint-allowlist.test.ts b/plugin-host/app/src/security/endpoint-allowlist.test.ts new file mode 100644 index 0000000000..e9d27243f6 --- /dev/null +++ b/plugin-host/app/src/security/endpoint-allowlist.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, it} from "vitest"; +import {antPatternToRegExp, isEndpointAllowed} from "./endpoint-allowlist"; + +describe("antPatternToRegExp", () => { + it("matches a literal pattern exactly", () => { + const re = antPatternToRegExp("/api/v1/document"); + expect(re.test("/api/v1/document")).toBe(true); + expect(re.test("/api/v1/documents")).toBe(false); + expect(re.test("/api/v1/document/123")).toBe(false); + }); + + it("lets * span one segment but never a slash", () => { + const re = antPatternToRegExp("/api/v1/document/*"); + expect(re.test("/api/v1/document/123")).toBe(true); + expect(re.test("/api/v1/document/")).toBe(true); + expect(re.test("/api/v1/document/123/note")).toBe(false); + }); + + it("supports * inside a segment", () => { + const re = antPatternToRegExp("/api/v1/document/*/note"); + expect(re.test("/api/v1/document/123/note")).toBe(true); + expect(re.test("/api/v1/document/123/456/note")).toBe(false); + }); + + it("lets ** span multiple segments, including none for a trailing /**", () => { + const re = antPatternToRegExp("/api/v1/case/**"); + expect(re.test("/api/v1/case")).toBe(true); + expect(re.test("/api/v1/case/x")).toBe(true); + expect(re.test("/api/v1/case/x/search")).toBe(true); + expect(re.test("/api/v1/cases")).toBe(false); + }); + + it("escapes regex metacharacters in the pattern", () => { + const re = antPatternToRegExp("/api/v1.0/foo"); + expect(re.test("/api/v1.0/foo")).toBe(true); + expect(re.test("/api/v1x0/foo")).toBe(false); + }); +}); + +describe("isEndpointAllowed", () => { + const endpoints = [ + { method: "GET", pattern: "/api/v1/document/*" }, + { method: "POST", pattern: "/api/v1/case/**" }, + ]; + + it("requires both the method and the pattern to match", () => { + expect(isEndpointAllowed("GET", "/api/v1/document/1", endpoints)).toBe(true); + expect(isEndpointAllowed("POST", "/api/v1/document/1", endpoints)).toBe(false); + expect(isEndpointAllowed("GET", "/api/v1/case/x/search", endpoints)).toBe(false); + expect(isEndpointAllowed("POST", "/api/v1/case/x/search", endpoints)).toBe(true); + }); + + it("matches the method case-insensitively and supports a wildcard method", () => { + expect(isEndpointAllowed("get", "/api/v1/document/1", endpoints)).toBe(true); + expect(isEndpointAllowed("DELETE", "/x", [{ method: "*", pattern: "/**" }])).toBe(true); + }); + + it("ignores the query string when matching", () => { + expect(isEndpointAllowed("GET", "/api/v1/document/1?full=true", endpoints)).toBe(true); + }); + + it("denies everything for an empty list", () => { + expect(isEndpointAllowed("GET", "/api/v1/document/1", [])).toBe(false); + }); +}); diff --git a/plugin-host/app/src/security/endpoint-allowlist.ts b/plugin-host/app/src/security/endpoint-allowlist.ts new file mode 100644 index 0000000000..972faa8fe1 --- /dev/null +++ b/plugin-host/app/src/security/endpoint-allowlist.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Endpoint } from "../models/index.js"; + +/** + * Ant-style endpoint pattern matching for the `gzac_api` allowlist, mirroring the backend's + * `ExternalPluginEndpointAllowlistFilter` (Spring `AntPathMatcher`) semantics for the subset the + * manifest uses: + * + * - `*` matches any number of characters **within** one path segment (never a `/`); + * - `**` matches any number of characters **across** segments (including none); + * - a trailing `/**` also matches the bare prefix path itself (`/api/x/**` matches `/api/x`). + * + * Both sides enforce the same list: GZAC's servlet filter is the authoritative gate, this module + * lets the host refuse a non-granted callback before it ever leaves the sidecar. + */ + +const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g; + +/** Compiles one Ant-style pattern to an anchored RegExp. */ +export function antPatternToRegExp(pattern: string): RegExp { + // A trailing "/**" matches the prefix itself too (Ant semantics), so peel it off first. + let suffix = ""; + let base = pattern; + if (base.endsWith("/**")) { + base = base.slice(0, -3); + suffix = "(/.*)?"; + } + const source = base + .split("**") + .map((part) => part.replace(REGEX_SPECIALS, "\\$&").replaceAll("*", "[^/]*")) + .join(".*"); + return new RegExp(`^${source}${suffix}$`); +} + +/** True when `method` + `path` (query string ignored) matches at least one granted endpoint. */ +export function isEndpointAllowed( + method: string, + path: string, + endpoints: Endpoint[] +): boolean { + const normalizedMethod = method.toUpperCase(); + const normalizedPath = path.split("?")[0]; + return endpoints.some( + (endpoint) => + (endpoint.method === "*" || endpoint.method.toUpperCase() === normalizedMethod) && + antPatternToRegExp(endpoint.pattern).test(normalizedPath) + ); +} diff --git a/plugin-host/app/src/security/hmac-auth.test.ts b/plugin-host/app/src/security/hmac-auth.test.ts new file mode 100644 index 0000000000..36c3a76a9a --- /dev/null +++ b/plugin-host/app/src/security/hmac-auth.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type {FastifyReply, FastifyRequest} from "fastify"; +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {computeBodyHash, computeSignature} from "./hmac"; +import { + createHmacAuthHook, + resetReplayCacheForTests, + verifyDeferredHmac, + verifyHmacRequest, +} from "./hmac-auth"; + +const SECRET = "hook-secret"; + +// The seen-signature cache is process-wide; clear it so specs that legitimately re-send the same +// signed request don't trip the replay guard from a previous test. +beforeEach(() => resetReplayCacheForTests()); + +function signedHeaders(method: string, path: string, body: Buffer, secret = SECRET) { + const timestamp = new Date().toISOString(); + const signature = computeSignature(secret, method, path, timestamp, computeBodyHash(body)); + return { "x-valtimo-signature": signature, "x-valtimo-timestamp": timestamp }; +} + +function fakeRequest(opts: { + method: string; + url: string; + headers?: Record; + rawBody?: Buffer; + deferHmac?: boolean; +}): FastifyRequest { + return { + method: opts.method, + url: opts.url, + headers: opts.headers ?? {}, + rawBody: opts.rawBody, + routeOptions: { config: { deferHmac: opts.deferHmac } }, + log: { warn: vi.fn() }, + } as unknown as FastifyRequest; +} + +function fakeReply() { + const reply = { + code: vi.fn((): typeof reply => reply), + send: vi.fn((): typeof reply => reply), + }; + return reply as unknown as FastifyReply & { code: ReturnType; send: ReturnType }; +} + +describe("createHmacAuthHook", () => { + it("passes a correctly signed request through (no reply sent)", async () => { + const body = Buffer.from('{"a":1}', "utf8"); + const request = fakeRequest({ + method: "POST", + url: "/api/host/configurations/x", + headers: signedHeaders("POST", "/api/host/configurations/x", body), + rawBody: body, + }); + const reply = fakeReply(); + + await createHmacAuthHook(SECRET)(request, reply, () => {}); + + expect(reply.code).not.toHaveBeenCalled(); + }); + + it("rejects an unsigned request with 401", async () => { + const request = fakeRequest({ method: "GET", url: "/api/host/plugins" }); + const reply = fakeReply(); + + await createHmacAuthHook(SECRET)(request, reply, () => {}); + + expect(reply.code).toHaveBeenCalledWith(401); + }); + + it("rejects a request signed with the wrong secret", async () => { + const request = fakeRequest({ + method: "GET", + url: "/api/host/plugins", + headers: signedHeaders("GET", "/api/host/plugins", Buffer.alloc(0), "attacker-secret"), + }); + const reply = fakeReply(); + + await createHmacAuthHook(SECRET)(request, reply, () => {}); + + expect(reply.code).toHaveBeenCalledWith(401); + }); + + it("binds an empty body for a request with no rawBody (GET/DELETE)", async () => { + const request = fakeRequest({ + method: "DELETE", + url: "/api/host/configurations/x", + headers: signedHeaders("DELETE", "/api/host/configurations/x", Buffer.alloc(0)), + }); + const reply = fakeReply(); + + await createHmacAuthHook(SECRET)(request, reply, () => {}); + + expect(reply.code).not.toHaveBeenCalled(); + }); + + it("rejects a replayed side-effecting request: the same signature is accepted only once", async () => { + const body = Buffer.from('{"a":1}', "utf8"); + const headers = signedHeaders("POST", "/api/host/configurations/x", body); + const makeRequest = () => + fakeRequest({ method: "POST", url: "/api/host/configurations/x", headers, rawBody: body }); + const hook = createHmacAuthHook(SECRET); + + const firstReply = fakeReply(); + await hook(makeRequest(), firstReply, () => {}); + expect(firstReply.code).not.toHaveBeenCalled(); + + // Byte-for-byte identical request (captured and resent) → refused. + const secondReply = fakeReply(); + await hook(makeRequest(), secondReply, () => {}); + expect(secondReply.code).toHaveBeenCalledWith(401); + }); + + it("does not replay-guard reads: the same signed GET may repeat", async () => { + const headers = signedHeaders("GET", "/api/host/plugins", Buffer.alloc(0)); + const hook = createHmacAuthHook(SECRET); + + for (let i = 0; i < 2; i++) { + const reply = fakeReply(); + await hook(fakeRequest({ method: "GET", url: "/api/host/plugins", headers }), reply, () => {}); + expect(reply.code).not.toHaveBeenCalled(); + } + }); + + it("skips verification entirely for a deferHmac route", async () => { + // No signature headers at all, yet the hook must not reject — the route verifies itself later. + const request = fakeRequest({ method: "POST", url: "/api/host/plugins", deferHmac: true }); + const reply = fakeReply(); + + await createHmacAuthHook(SECRET)(request, reply, () => {}); + + expect(reply.code).not.toHaveBeenCalled(); + }); +}); + +describe("verifyHmacRequest", () => { + it("signs over the path with the query string removed", () => { + const path = "/api/host/configurations"; + const request = fakeRequest({ + method: "GET", + url: `${path}?foo=bar&baz=1`, + headers: signedHeaders("GET", path, Buffer.alloc(0)), + }); + + expect(verifyHmacRequest(request, SECRET, Buffer.alloc(0)).valid).toBe(true); + }); +}); + +describe("verifyDeferredHmac", () => { + it("returns true and sends nothing for a body-bound valid signature", () => { + const fileBytes = Buffer.from("PK-zip-bytes"); + const request = fakeRequest({ + method: "POST", + url: "/api/host/plugins", + headers: signedHeaders("POST", "/api/host/plugins", fileBytes), + }); + const reply = fakeReply(); + + expect(verifyDeferredHmac(request, reply, SECRET, fileBytes)).toBe(true); + expect(reply.code).not.toHaveBeenCalled(); + }); + + it("returns false and replies 401 when the bound body does not match the signature", () => { + const request = fakeRequest({ + method: "POST", + url: "/api/host/plugins", + headers: signedHeaders("POST", "/api/host/plugins", Buffer.from("original")), + }); + const reply = fakeReply(); + + expect(verifyDeferredHmac(request, reply, SECRET, Buffer.from("tampered"))).toBe(false); + expect(reply.code).toHaveBeenCalledWith(401); + }); + + it("rejects a replayed upload: the same file-bound signature is accepted only once", () => { + const fileBytes = Buffer.from("PK-zip-bytes"); + const headers = signedHeaders("POST", "/api/host/plugins", fileBytes); + const makeRequest = () => + fakeRequest({ method: "POST", url: "/api/host/plugins", headers }); + + expect(verifyDeferredHmac(makeRequest(), fakeReply(), SECRET, fileBytes)).toBe(true); + + const reply = fakeReply(); + expect(verifyDeferredHmac(makeRequest(), reply, SECRET, fileBytes)).toBe(false); + expect(reply.code).toHaveBeenCalledWith(401); + }); +}); diff --git a/plugin-host/app/src/security/hmac-auth.ts b/plugin-host/app/src/security/hmac-auth.ts new file mode 100644 index 0000000000..15859dd9bb --- /dev/null +++ b/plugin-host/app/src/security/hmac-auth.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + FastifyReply, + FastifyRequest, + preHandlerHookHandler, +} from "fastify"; +import { + verifyHmac, + SIGNATURE_HEADER, + TIMESTAMP_HEADER, + type HmacVerificationResult, +} from "./hmac.js"; +import { ReplayCache } from "./replay-cache.js"; + +declare module "fastify" { + interface FastifyContextConfig { + // Opt a route out of the shared raw-body HMAC hook because it binds a different body + // representation. The multipart upload route signs the uploaded file bytes (not the multipart + // envelope, whose boundary the signer cannot reproduce), so it verifies inside its own handler. + deferHmac?: boolean; + } +} + +function rawBodyOf(request: FastifyRequest): Buffer { + return (request as unknown as { rawBody?: Buffer }).rawBody ?? Buffer.alloc(0); +} + +/** + * Process-wide seen-signature cache: every HMAC-authenticated route shares it, so a signature + * accepted by one route can never be replayed against another. Only side-effecting methods are + * checked — replaying a GET is harmless and read routes may legitimately repeat. + */ +const SIDE_EFFECTING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]); +const replayCache = new ReplayCache(); + +/** Empties the shared replay cache. For tests only, which re-send identical signed requests. */ +export function resetReplayCacheForTests(): void { + replayCache.clear(); +} + +/** + * Rejects a *verified* request whose signature was already accepted once within the replay window. + * The signature binds method+path+timestamp+bodyHash, so an identical signature means a byte-for- + * byte identical request — a replay (or a duplicate send within the same timestamp granularity, + * which callers must avoid by using millisecond-precision timestamps). + */ +function isReplay(request: FastifyRequest): boolean { + if (!SIDE_EFFECTING_METHODS.has(request.method.toUpperCase())) return false; + const signature = request.headers[SIGNATURE_HEADER] as string; + return replayCache.checkAndRecord(signature); +} + +function rejectUnauthorized( + request: FastifyRequest, + reply: FastifyReply, + error: string | undefined +): void { + request.log.warn({ error, path: request.url }, "HMAC verification failed"); + reply.code(401).send({ error: "Unauthorized: " + error }); +} + +/** + * Verifies the HMAC signature of an incoming request against an explicit body buffer. The canonical + * string is `{METHOD}\n{path}\n{timestamp}\n{bodyHash}` (see hmac.ts), matching the backend's + * ExternalPluginHmacSigner. The path is `request.url` minus the query string. + */ +export function verifyHmacRequest( + request: FastifyRequest, + secret: string, + body: Buffer +): HmacVerificationResult { + return verifyHmac( + secret, + request.method, + request.url.split("?")[0], + request.headers[SIGNATURE_HEADER] as string | undefined, + request.headers[TIMESTAMP_HEADER] as string | undefined, + body + ); +} + +/** + * preHandler that authenticates GZAC→host requests by HMAC signature over the captured raw body. + * The HMAC key is the host's `ADMIN_TOKEN` — the shared secret carried as a replay-windowed, + * body-bound signature rather than a static bearer. Routes that opt in to raw-body capture + * (`config.rawBody`) bind their JSON body; routes that do not bind an empty body (GET/DELETE). + * Routes flagged `config.deferHmac` are skipped here and verify themselves once their body is read. + * + * Side-effecting requests (POST/PUT/DELETE) are additionally checked against the shared + * seen-signature cache, closing the replay window the ±5-minute timestamp drift check leaves open. + */ +export function createHmacAuthHook(secret: string): preHandlerHookHandler { + return async (request: FastifyRequest, reply: FastifyReply) => { + if (request.routeOptions.config?.deferHmac) { + return; + } + const result = verifyHmacRequest(request, secret, rawBodyOf(request)); + if (!result.valid) { + rejectUnauthorized(request, reply, result.error); + return; + } + if (isReplay(request)) { + rejectUnauthorized(request, reply, "Duplicate signature (possible replay)"); + } + }; +} + +/** + * Verifies an HMAC-signed request whose signed body is an explicit buffer rather than the raw HTTP + * body, replying 401 when invalid. Used by the multipart plugin-upload route, which signs the + * uploaded file bytes. Returns true when the request is authentic. + */ +export function verifyDeferredHmac( + request: FastifyRequest, + reply: FastifyReply, + secret: string, + body: Buffer +): boolean { + const result = verifyHmacRequest(request, secret, body); + if (!result.valid) { + rejectUnauthorized(request, reply, result.error); + return false; + } + if (isReplay(request)) { + rejectUnauthorized(request, reply, "Duplicate signature (possible replay)"); + return false; + } + return true; +} diff --git a/plugin-host/app/src/security/hmac.test.ts b/plugin-host/app/src/security/hmac.test.ts new file mode 100644 index 0000000000..57532817aa --- /dev/null +++ b/plugin-host/app/src/security/hmac.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {readFileSync} from "node:fs"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {computeBodyHash, computeSignature, verifyHmac} from "./hmac"; + +interface HmacVector { + name: string; + method: string; + path: string; + timestamp: string; + body: string; + bodyHash: string; + expectedSignature: string; +} + +// Shared cross-language golden vectors (plan §5). expectedSignature/bodyHash were produced by an +// independent oracle (openssl), so this asserts parity with the Kotlin ExternalPluginHmacSigner +// rather than the Node implementation checking itself. +const fixture = JSON.parse( + readFileSync(new URL("../../../test-fixtures/hmac-vectors.json", import.meta.url), "utf-8") +) as { secret: string; vectors: HmacVector[] }; + +const SECRET = fixture.secret; + +describe("HMAC golden-vector parity (cross-language, §3.9/§5)", () => { + it.each(fixture.vectors)("computeBodyHash matches the oracle for $name", (v) => { + expect(computeBodyHash(Buffer.from(v.body, "utf8"))).toBe(v.bodyHash); + }); + + it.each(fixture.vectors)("computeSignature matches the oracle for $name", (v) => { + expect(computeSignature(SECRET, v.method, v.path, v.timestamp, v.bodyHash)).toBe( + v.expectedSignature + ); + }); + + it("upper-cases the method before signing (a lowercase method signs identically)", () => { + const v = fixture.vectors[0]; + expect(computeSignature(SECRET, v.method.toLowerCase(), v.path, v.timestamp, v.bodyHash)).toBe( + v.expectedSignature + ); + }); +}); + +describe("verifyHmac", () => { + const v = fixture.vectors[0]; // json-push-body + const body = () => Buffer.from(v.body, "utf8"); + + beforeEach(() => { + // Pin "now" to the vector's timestamp so a correctly-signed request is inside the drift window. + vi.useFakeTimers(); + vi.setSystemTime(new Date(v.timestamp)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("accepts a correctly signed, in-window request", () => { + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, v.timestamp, body()); + expect(result.valid).toBe(true); + }); + + it("rejects a missing signature header", () => { + const result = verifyHmac(SECRET, v.method, v.path, undefined, v.timestamp, body()); + expect(result).toEqual({ valid: false, error: "Missing signature header" }); + }); + + it("rejects a missing timestamp header", () => { + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, undefined, body()); + expect(result).toEqual({ valid: false, error: "Missing timestamp header" }); + }); + + it("rejects an unparseable timestamp", () => { + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, "not-a-date", body()); + expect(result).toEqual({ valid: false, error: "Invalid timestamp format" }); + }); + + it("accepts a request just inside the ±5-minute drift window", () => { + vi.setSystemTime(new Date(Date.parse(v.timestamp) + 4 * 60_000 + 59_000)); + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, v.timestamp, body()); + expect(result.valid).toBe(true); + }); + + it("rejects a request just outside the ±5-minute drift window", () => { + vi.setSystemTime(new Date(Date.parse(v.timestamp) + 5 * 60_000 + 1_000)); + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, v.timestamp, body()); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/Timestamp drift too large/); + }); + + it("rejects a future timestamp outside the window (abs drift)", () => { + vi.setSystemTime(new Date(Date.parse(v.timestamp) - 6 * 60_000)); + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, v.timestamp, body()); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/Timestamp drift too large/); + }); + + it("rejects a signature made with the wrong secret", () => { + const forged = computeSignature("wrong-secret", v.method, v.path, v.timestamp, v.bodyHash); + const result = verifyHmac(SECRET, v.method, v.path, forged, v.timestamp, body()); + expect(result).toEqual({ valid: false, error: "Invalid signature" }); + }); + + it("rejects when the body was tampered with after signing", () => { + const tampered = Buffer.from(v.body + "X", "utf8"); + const result = verifyHmac(SECRET, v.method, v.path, v.expectedSignature, v.timestamp, tampered); + expect(result).toEqual({ valid: false, error: "Invalid signature" }); + }); + + it("rejects a signature of the wrong length before the timing-safe compare", () => { + const result = verifyHmac(SECRET, v.method, v.path, "deadbeef", v.timestamp, body()); + expect(result).toEqual({ valid: false, error: "Invalid signature" }); + }); +}); diff --git a/plugin-host/app/src/security/hmac.ts b/plugin-host/app/src/security/hmac.ts new file mode 100644 index 0000000000..6602c95466 --- /dev/null +++ b/plugin-host/app/src/security/hmac.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createHmac, createHash, timingSafeEqual } from "node:crypto"; + +export const SIGNATURE_HEADER = "x-valtimo-signature"; +export const TIMESTAMP_HEADER = "x-valtimo-timestamp"; + +const ALGORITHM = "sha256"; +const MAX_TIMESTAMP_DRIFT_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Computes HMAC-SHA256 signature over the canonical request string. + * + * The payload format matches the backend's ExternalPluginHmacSigner: + * `{METHOD}\n{path}\n{timestamp}\n{bodyHash}` + */ +export function computeSignature( + secret: string, + method: string, + path: string, + timestamp: string, + bodyHash: string +): string { + const payload = `${method.toUpperCase()}\n${path}\n${timestamp}\n${bodyHash}`; + return createHmac(ALGORITHM, secret).update(payload, "utf8").digest("hex"); +} + +/** + * Computes SHA-256 hash of the request body. + */ +export function computeBodyHash(body: Buffer): string { + return createHash(ALGORITHM).update(body).digest("hex"); +} + +export interface HmacVerificationResult { + valid: boolean; + error?: string; +} + +/** + * Verifies the HMAC signature on an incoming request. + * + * Checks: + * 1. Signature header is present + * 2. Timestamp header is present and within acceptable drift + * 3. Computed signature matches the provided signature (timing-safe comparison) + */ +export function verifyHmac( + secret: string, + method: string, + path: string, + signatureHeader: string | undefined, + timestampHeader: string | undefined, + body: Buffer +): HmacVerificationResult { + if (!signatureHeader) { + return { valid: false, error: "Missing signature header" }; + } + + if (!timestampHeader) { + return { valid: false, error: "Missing timestamp header" }; + } + + // Validate timestamp is not too old or too far in the future (replay protection) + const requestTime = Date.parse(timestampHeader); + if (isNaN(requestTime)) { + return { valid: false, error: "Invalid timestamp format" }; + } + + const now = Date.now(); + const drift = Math.abs(now - requestTime); + if (drift > MAX_TIMESTAMP_DRIFT_MS) { + return { + valid: false, + error: `Timestamp drift too large: ${Math.round(drift / 1000)}s (max ${MAX_TIMESTAMP_DRIFT_MS / 1000}s)`, + }; + } + + const bodyHash = computeBodyHash(body); + const expectedSignature = computeSignature( + secret, + method, + path, + timestampHeader, + bodyHash + ); + + // Use timing-safe comparison to prevent timing attacks + const sigBuffer = Buffer.from(signatureHeader, "utf8"); + const expectedBuffer = Buffer.from(expectedSignature, "utf8"); + + if (sigBuffer.length !== expectedBuffer.length) { + return { valid: false, error: "Invalid signature" }; + } + + if (!timingSafeEqual(sigBuffer, expectedBuffer)) { + return { valid: false, error: "Invalid signature" }; + } + + return { valid: true }; +} diff --git a/plugin-host/app/src/security/replay-cache.ts b/plugin-host/app/src/security/replay-cache.ts new file mode 100644 index 0000000000..1dad547455 --- /dev/null +++ b/plugin-host/app/src/security/replay-cache.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * In-memory seen-signature cache backing HMAC replay rejection. + * + * The timestamp drift check alone leaves a ±5-minute window in which a captured + * signature+timestamp pair could be resent verbatim. Recording every accepted signature until its + * timestamp falls out of that window closes the gap: a second request carrying the *same* + * signature is a replay (the signature covers method, path, timestamp and body hash, so any + * legitimate new request differs in at least the timestamp). + * + * Only consulted for side-effecting methods (POST/PUT/DELETE) — see hmac-auth.ts. Expired entries + * are evicted lazily on insert, at most once per sweep interval, so the cache needs no timer. + */ +export class ReplayCache { + private readonly seen = new Map(); + private lastSweepAt = 0; + + constructor( + /** How long an accepted signature stays blocked — at least the HMAC drift window. */ + private readonly ttlMs: number = 10 * 60 * 1000, + /** Minimum interval between lazy eviction sweeps. */ + private readonly sweepIntervalMs: number = 60 * 1000 + ) {} + + /** + * Records `signature` and reports whether it was already present (and unexpired) — i.e. whether + * this request is a replay. + */ + checkAndRecord(signature: string, now: number = Date.now()): boolean { + this.sweep(now); + const expiry = this.seen.get(signature); + if (expiry !== undefined && expiry > now) { + return true; + } + this.seen.set(signature, now + this.ttlMs); + return false; + } + + /** Drops expired entries; runs at most once per sweep interval. */ + private sweep(now: number): void { + if (now - this.lastSweepAt < this.sweepIntervalMs) return; + this.lastSweepAt = now; + for (const [signature, expiry] of this.seen) { + if (expiry <= now) this.seen.delete(signature); + } + } + + /** Empties the cache. Intended for tests, which replay identical signed requests on purpose. */ + clear(): void { + this.seen.clear(); + this.lastSweepAt = 0; + } + + get size(): number { + return this.seen.size; + } +} diff --git a/plugin-host/app/src/security/url-guard.ts b/plugin-host/app/src/security/url-guard.ts new file mode 100644 index 0000000000..914edc5a1e --- /dev/null +++ b/plugin-host/app/src/security/url-guard.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BlockList, isIP } from "node:net"; +import { lookup } from "node:dns"; +import type { LookupAddress, LookupOptions } from "node:dns"; +import { Agent } from "undici"; + +/** + * SSRF guard for the `http_request` host function: plugins supply arbitrary URLs, so without this + * check a malicious plugin could direct the host to call services that are only reachable from the + * host's network position — loopback services, the host's own admin API, cloud metadata endpoints + * (169.254.169.254), or anything on the private LAN. + * + * Enforcement happens at connection time: {@link createGuardedAgent} returns an undici dispatcher + * whose DNS lookup rejects any hostname that resolves to a private/reserved address, so the check + * is pinned to the exact addresses the socket would connect to. This closes DNS rebinding — there + * is no separate validate-then-resolve step for a flipping DNS record to exploit — and it covers + * every request through the agent, including redirect hops. IP-literal hosts bypass DNS entirely, + * so callers must additionally reject those up front via {@link findBlockedIpLiteral}. + */ + +const PRIVATE_ADDRESS_ERROR_CODE = "EPRIVATEADDRESS"; + +const blockedIpv4 = new BlockList(); +// "This network" +blockedIpv4.addSubnet("0.0.0.0", 8); +// RFC1918 private ranges +blockedIpv4.addSubnet("10.0.0.0", 8); +blockedIpv4.addSubnet("172.16.0.0", 12); +blockedIpv4.addSubnet("192.168.0.0", 16); +// Carrier-grade NAT +blockedIpv4.addSubnet("100.64.0.0", 10); +// Loopback +blockedIpv4.addSubnet("127.0.0.0", 8); +// Link-local, incl. cloud metadata services on 169.254.169.254 +blockedIpv4.addSubnet("169.254.0.0", 16); +// IETF protocol assignments and benchmarking +blockedIpv4.addSubnet("192.0.0.0", 24); +blockedIpv4.addSubnet("198.18.0.0", 15); +// Multicast, reserved-for-future-use, and broadcast (224.0.0.0–255.255.255.255) +blockedIpv4.addSubnet("224.0.0.0", 3); + +const blockedIpv6 = new BlockList(); +// Unspecified and loopback +blockedIpv6.addSubnet("::", 127, "ipv6"); +// Link-local and unique-local +blockedIpv6.addSubnet("fe80::", 10, "ipv6"); +blockedIpv6.addSubnet("fc00::", 7, "ipv6"); +// Multicast +blockedIpv6.addSubnet("ff00::", 8, "ipv6"); +// NAT64 well-known prefix — embeds an IPv4 address a translator would connect to +blockedIpv6.addSubnet("64:ff9b::", 96, "ipv6"); + +export function isPrivateOrReservedAddress(address: string): boolean { + const family = isIP(address); + if (family === 0) return true; // not an IP literal — only resolved addresses reach this check + if (family === 6) { + // An IPv4-mapped/compatible IPv6 literal (e.g. ::ffff:127.0.0.1) connects to the embedded + // IPv4 address, so judge it by its IPv4 rules. + const embedded = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/i.exec(address); + if (embedded) return isPrivateOrReservedAddress(embedded[1]); + return blockedIpv6.check(address, "ipv6"); + } + return blockedIpv4.check(address); +} + +/** + * Returns a rejection reason when the URL's host is an IP literal in a private/reserved range, + * else null. Complements the guarded agent: connections to IP literals skip DNS, so the agent's + * lookup guard never sees them. + */ +export function findBlockedIpLiteral(url: URL): string | null { + // URL.hostname keeps the brackets around IPv6 literals + const host = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(host) !== 0 && isPrivateOrReservedAddress(host)) { + return `IP address ${host} is in a private or reserved range`; + } + return null; +} + +/** True when `err` (or anything in its `cause` chain) is this guard's private-address rejection. */ +export function isPrivateAddressError(err: unknown): boolean { + for (let e = err; e instanceof Error; e = e.cause) { + if ((e as NodeJS.ErrnoException).code === PRIVATE_ADDRESS_ERROR_CODE) return true; + } + return false; +} + +/** The innermost `cause` message — undici wraps connection errors in a generic "fetch failed". */ +export function rootCauseMessage(err: unknown): string { + let current = err; + while (current instanceof Error && current.cause instanceof Error) { + current = current.cause; + } + return current instanceof Error ? current.message : String(err); +} + +/** + * A DNS lookup for the agent's connector that fails the connection when ANY resolved address is + * private/reserved (the runtime may pick any of them, and a mixed public/private record set is a + * classic rebinding trick). Handles both callback shapes `net.connect` uses: single-address, and + * all-addresses when Happy Eyeballs (`autoSelectFamily`) is active. + */ +function guardedLookup( + hostname: string, + options: LookupOptions, + callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void +): void { + lookup(hostname, { ...options, all: true }, (err, addresses) => { + if (err) return callback(err, []); + const list = addresses as LookupAddress[]; + const blocked = list.find((entry) => isPrivateOrReservedAddress(entry.address)); + if (list.length === 0 || blocked) { + const reason = blocked + ? `Hostname '${hostname}' resolves to ${blocked.address}, which is in a private or reserved range` + : `Hostname '${hostname}' did not resolve to any address`; + const error: NodeJS.ErrnoException = new Error(reason); + error.code = PRIVATE_ADDRESS_ERROR_CODE; + return callback(error, []); + } + if (options.all) return callback(null, list); + callback(null, list[0].address, list[0].family); + }); +} + +/** + * An undici dispatcher that refuses to open sockets towards private/reserved addresses. + * Pass it as `dispatcher` on every fetch call (redirect hops included when redirects are + * followed manually). + */ +export function createGuardedAgent(): Agent { + // `connect` options are forwarded to net/tls.connect, which accepts a custom `lookup`; + // undici's types don't declare it, hence the cast. + return new Agent({ connect: { lookup: guardedLookup } as object }); +} diff --git a/plugin-host/app/src/security/user-token-introspection.test.ts b/plugin-host/app/src/security/user-token-introspection.test.ts new file mode 100644 index 0000000000..1c9ea7f844 --- /dev/null +++ b/plugin-host/app/src/security/user-token-introspection.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {UserTokenIntrospector} from "./user-token-introspection"; + +const GZAC = "http://gzac:8080"; +const INTROSPECT_URL = `${GZAC}/api/v1/external-plugin/user-token/introspect`; + +function okResponse(configurationId = "cfg-1", expiresInMs = 15 * 60 * 1000): Response { + return new Response( + JSON.stringify({ + subject: "john@example.com", + configurationId, + expiresAt: new Date(Date.now() + expiresInMs).toISOString(), + }), + { status: 200 } + ); +} + +describe("UserTokenIntrospector", () => { + let fetchMock: ReturnType; + let introspector: UserTokenIntrospector; + + beforeEach(() => { + fetchMock = vi.fn(async () => okResponse()); + vi.stubGlobal("fetch", fetchMock); + introspector = new UserTokenIntrospector(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("presents the token as the bearer credential to GZAC's introspect endpoint", async () => { + const result = await introspector.introspect(GZAC, "tok-1"); + + expect(result).toEqual({ kind: "valid", configurationId: "cfg-1" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(INTROSPECT_URL); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer tok-1"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("normalises a trailing slash on the GZAC base URL", async () => { + await introspector.introspect(`${GZAC}/`, "tok-1"); + expect(fetchMock.mock.calls[0][0]).toBe(INTROSPECT_URL); + }); + + it("serves a repeated token from the cache without a second network call", async () => { + await introspector.introspect(GZAC, "tok-1"); + const second = await introspector.introspect(GZAC, "tok-1"); + + expect(second).toEqual({ kind: "valid", configurationId: "cfg-1" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("caches per token — a different token triggers its own introspection", async () => { + fetchMock + .mockResolvedValueOnce(okResponse("cfg-1")) + .mockResolvedValueOnce(okResponse("cfg-2")); + + const first = await introspector.introspect(GZAC, "tok-1"); + const second = await introspector.introspect(GZAC, "tok-2"); + + expect(first).toEqual({ kind: "valid", configurationId: "cfg-1" }); + expect(second).toEqual({ kind: "valid", configurationId: "cfg-2" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("re-introspects once the 60s cache window has passed", async () => { + vi.useFakeTimers(); + await introspector.introspect(GZAC, "tok-1"); + + vi.setSystemTime(Date.now() + 61_000); + await introspector.introspect(GZAC, "tok-1"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("caps the cache window at the token's own expiry when that is sooner", async () => { + vi.useFakeTimers(); + fetchMock.mockResolvedValue(okResponse("cfg-1", 5_000)); // token expires in 5s + + await introspector.introspect(GZAC, "tok-1"); + vi.setSystemTime(Date.now() + 6_000); // < 60s, but past the token expiry + await introspector.introspect(GZAC, "tok-1"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps a 401 to invalid and does not cache the rejection", async () => { + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })); + + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "invalid" }); + // A retry after the rejection asks GZAC again (a freshly minted token must not stay locked out). + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ + kind: "valid", + configurationId: "cfg-1", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps a 403 to invalid", async () => { + fetchMock.mockResolvedValueOnce(new Response("", { status: 403 })); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "invalid" }); + }); + + it("maps an unreachable GZAC to unavailable", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + }); + + it("maps a timeout to unavailable", async () => { + fetchMock.mockRejectedValueOnce(new DOMException("The operation timed out.", "TimeoutError")); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + }); + + it("maps an unexpected 5xx to unavailable (no verdict on the token)", async () => { + fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 })); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + }); + + it("maps a malformed 200 body to unavailable", async () => { + fetchMock.mockResolvedValueOnce(new Response("not-json", { status: 200 })); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ subject: "x" }), { status: 200 })); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ configurationId: "cfg-1", expiresAt: "not-a-date" }), + { status: 200 } + ) + ); + expect(await introspector.introspect(GZAC, "tok-1")).toEqual({ kind: "unavailable" }); + }); +}); diff --git a/plugin-host/app/src/security/user-token-introspection.ts b/plugin-host/app/src/security/user-token-introspection.ts new file mode 100644 index 0000000000..84aac49c96 --- /dev/null +++ b/plugin-host/app/src/security/user-token-introspection.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {createHash} from "node:crypto"; + +/** + * Remote introspection of GZAC-minted downscoped user tokens. + * + * The host cannot validate the HS256 user token locally — the signing key never leaves GZAC — so + * the /data route validates it by calling GZAC's introspection endpoint + * (`GET /api/v1/external-plugin/user-token/introspect`) *with the token itself* as the bearer + * credential. GZAC's user-token filter authenticates it and the resource echoes the token's own + * claims back: `{ subject, configurationId, expiresAt }`. + * + * Outcomes are deliberately trichotomous so the route can fail closed: + * - `valid` — GZAC accepted the token; carries the configuration id the token is bound to. + * - `invalid` — GZAC rejected it (401/403): expired, forged, or not a user token. + * - `unavailable` — GZAC was unreachable, timed out, or answered unusably. The token's validity is + * UNKNOWN; the route must respond 503 and never execute Wasm on an unvalidated token. + * + * Positive results are cached in-memory, keyed by a SHA-256 hash of the token (the raw token is + * never used as a map key), valid until `min(token expiresAt, now + CACHE_TTL_MS)` — so repeated + * /data calls with the same token cost one GZAC round-trip per minute at most. Negative results + * are not cached: rejections are cheap for GZAC and caching them could lock out a freshly minted + * token. Expired entries are evicted lazily on lookup, like the route's rate-limit window map. + */ + +export type IntrospectionOutcome = + | { kind: "valid"; configurationId: string } + | { kind: "invalid" } + | { kind: "unavailable" }; + +const DEFAULT_TIMEOUT_MS = 10_000; +/** Upper bound on how long a positive introspection is reused without re-asking GZAC. */ +const CACHE_TTL_MS = 60_000; + +export class UserTokenIntrospector { + private readonly timeoutMs: number; + private readonly cache = new Map(); + + constructor(options: { timeoutMs?: number } = {}) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + async introspect(gzacBaseUrl: string, userToken: string): Promise { + const key = createHash("sha256").update(userToken, "utf8").digest("hex"); + const now = Date.now(); + + const cached = this.cache.get(key); + if (cached) { + if (cached.validUntilMs > now) { + return { kind: "valid", configurationId: cached.configurationId }; + } + // Lazy eviction: a stale entry is dropped when (and only when) its token shows up again. + this.cache.delete(key); + } + + const url = `${gzacBaseUrl.replace(/\/$/, "")}/api/v1/external-plugin/user-token/introspect`; + let res: Response; + try { + res = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${userToken}`, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch { + // Timeout, DNS failure, connection refused, … — GZAC didn't answer the question. + return { kind: "unavailable" }; + } + + if (res.status === 401 || res.status === 403) { + return { kind: "invalid" }; + } + if (res.status !== 200) { + // 5xx / unexpected status: not a verdict on the token — treat as unavailable (fail closed). + return { kind: "unavailable" }; + } + + let body: { configurationId?: unknown; expiresAt?: unknown }; + try { + body = (await res.json()) as never; + } catch { + return { kind: "unavailable" }; + } + if (typeof body?.configurationId !== "string" || typeof body?.expiresAt !== "string") { + return { kind: "unavailable" }; + } + const expiresAtMs = Date.parse(body.expiresAt); + if (Number.isNaN(expiresAtMs)) { + return { kind: "unavailable" }; + } + + const validUntilMs = Math.min(expiresAtMs, now + CACHE_TTL_MS); + if (validUntilMs > now) { + this.cache.set(key, { configurationId: body.configurationId, validUntilMs }); + } + return { kind: "valid", configurationId: body.configurationId }; + } +} diff --git a/plugin-host/app/src/test-support/harness.ts b/plugin-host/app/src/test-support/harness.ts new file mode 100644 index 0000000000..85df16cc6a --- /dev/null +++ b/plugin-host/app/src/test-support/harness.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {createHash, createHmac} from "node:crypto"; +import Fastify, {type FastifyInstance} from "fastify"; +import rawBody from "fastify-raw-body"; +import multipart from "@fastify/multipart"; +import type {AppConfig} from "../models/index.js"; + +/** Shared secret used across route tests — the HMAC key for GZAC→host authentication. */ +export const ADMIN_TOKEN = "test-admin-secret"; + +/** + * Builds a Fastify instance wired exactly like production (raw-body capture for HMAC + multipart for + * uploads), then invokes the caller to register the routes under test. Logging is off so specs stay + * quiet. + */ +export async function buildTestApp( + register: (app: FastifyInstance) => Promise +): Promise { + const app = Fastify({ logger: false }); + await app.register(rawBody, { + field: "rawBody", + global: false, + encoding: false, + runFirst: true, + }); + await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } }); + await register(app); + await app.ready(); + return app; +} + +/** + * Produces the HMAC headers a legitimate GZAC client would send. Uses node:crypto directly (an + * independent signer from the host's own hmac.ts) over the canonical + * `{METHOD}\n{path}\n{timestamp}\n{bodyHash}` string, so a route+hook test proves the hook accepts a + * genuinely-signed request rather than one produced by the code under test. + */ +export function signHeaders( + method: string, + path: string, + body: Buffer | string = Buffer.alloc(0), + secret: string = ADMIN_TOKEN, + timestamp: string = new Date().toISOString() +): Record { + const bodyBuffer = typeof body === "string" ? Buffer.from(body, "utf8") : body; + const bodyHash = createHash("sha256").update(bodyBuffer).digest("hex"); + const payload = `${method.toUpperCase()}\n${path}\n${timestamp}\n${bodyHash}`; + const signature = createHmac("sha256", secret).update(payload, "utf8").digest("hex"); + return { + "x-valtimo-signature": signature, + "x-valtimo-timestamp": timestamp, + }; +} + +/** A minimal AppConfig for route tests. Only the fields the routes actually read need be real. */ +export function testConfig(overrides: Partial = {}): AppConfig { + return { + PORT: 8090, + ADMIN_TOKEN, + PLUGIN_STORAGE_DIR: "./plugins", + LOG_LEVEL: "info", + HOST_ID: "test-host", + DB_HOST: "localhost", + DB_PORT: 5434, + DB_NAME: "pluginhost", + DB_USER: "pluginhost", + DB_PASSWORD: "pluginhost", + WASM_TIMEOUT_MS: 30_000, + WASM_MAX_MEMORY_PAGES: 4096, + WASM_INSTANCE_IDLE_TTL_MS: 10 * 60 * 1000, + GZAC_API_TIMEOUT_MS: 60_000, + USER_TOKEN_INTROSPECTION_TIMEOUT_MS: 10_000, + UPLOAD_MAX_BYTES: 25 * 1024 * 1024, + DATA_RATE_LIMIT_PER_MINUTE: 120, + CONFIG_CACHE_TTL_MS: 10_000, + ...overrides, + } as AppConfig; +} diff --git a/plugin-host/app/test/integration/config-repository.int.test.ts b/plugin-host/app/test/integration/config-repository.int.test.ts new file mode 100644 index 0000000000..a759054716 --- /dev/null +++ b/plugin-host/app/test/integration/config-repository.int.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {PostgreSqlContainer, type StartedPostgreSqlContainer} from "@testcontainers/postgresql"; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from "vitest"; +import {ConfigRegistry} from "../../src/config-registry.js"; +import {ConfigRepository} from "../../src/db/config-repository.js"; +import {closeDbPool, createDbPool, type DbPool, runMigrations} from "../../src/db/index.js"; +import type {HostLogger, PluginConfiguration} from "../../src/models/index.js"; + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +function config(overrides: Partial = {}): PluginConfiguration { + return { + configurationId: "cfg-1", + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: { nested: { a: 1 }, list: [1, 2, 3] }, + serviceToken: "svc-token", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["com.ritense.valtimo.document.created"], + grantedCapabilities: [], + eventBroker: { + amqpUrl: "amqp://broker", + exchange: "valtimo-events", + exchangeType: "fanout", + queueMode: "durable", + queueTtlMs: 259_200_000, + }, + ...overrides, + }; +} + +describe("ConfigRepository against real Postgres", () => { + let container: StartedPostgreSqlContainer; + let pool: DbPool; + let repo: ConfigRepository; + + beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:16-alpine").start(); + pool = await createDbPool( + { + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }, + noopLogger() + ); + await runMigrations(pool, noopLogger()); + repo = new ConfigRepository(pool); + }); + + afterAll(async () => { + if (pool) await closeDbPool(pool); + if (container) await container.stop(); + }); + + beforeEach(async () => { + await pool.query("TRUNCATE plugin_configurations"); + }); + + it("round-trips a configuration including JSON columns", async () => { + await repo.set("cfg-1", config()); + const got = await repo.get("cfg-1"); + + expect(got).toEqual(config()); // properties, eventSubscriptions and eventBroker rehydrate from JSONB + }); + + it("round-trips granted capabilities and endpoints; an absent endpoint list stays absent", async () => { + await repo.set("cfg-1", config({ + grantedCapabilities: ["gzac_api", "frontend_data"], + grantedEndpoints: [{ method: "GET", pattern: "/api/v1/document/*" }], + })); + const got = await repo.get("cfg-1"); + expect(got?.grantedCapabilities).toEqual(["gzac_api", "frontend_data"]); + expect(got?.grantedEndpoints).toEqual([{ method: "GET", pattern: "/api/v1/document/*" }]); + + // No grantedEndpoints pushed (older GZAC) → SQL NULL → undefined, NOT [] — the host relies on + // this distinction: undefined = "no allowlist pushed, warn+allow", [] = "deny everything". + await repo.set("cfg-2", config({ configurationId: "cfg-2" })); + expect((await repo.get("cfg-2"))?.grantedEndpoints).toBeUndefined(); + + // An empty pushed list round-trips as an empty list (deny all). + await repo.set("cfg-3", config({ configurationId: "cfg-3", grantedEndpoints: [] })); + expect((await repo.get("cfg-3"))?.grantedEndpoints).toEqual([]); + }); + + it("returns undefined for a missing configuration", async () => { + expect(await repo.get("nope")).toBeUndefined(); + }); + + it("upserts on conflicting configuration_id (set is idempotent by id)", async () => { + await repo.set("cfg-1", config({ serviceToken: "first" })); + await repo.set("cfg-1", config({ serviceToken: "second", properties: { changed: true } })); + + const got = await repo.get("cfg-1"); + expect(got?.serviceToken).toBe("second"); + expect(got?.properties).toEqual({ changed: true }); + const all = await repo.list(); + expect(all).toHaveLength(1); // upsert, not insert + }); + + it("stores a null event_broker when events are disabled", async () => { + await repo.set("cfg-1", config({ eventBroker: undefined })); + const got = await repo.get("cfg-1"); + // A disabled broker is SQL NULL and rehydrates as `null` (not `undefined`). The event-consumer's + // `cfg.eventBroker?.amqpUrl` guards treat null and undefined alike, so this is harmless — but it + // is the actual mapRow behaviour, so pin it rather than the optimistic `undefined`. + expect(got?.eventBroker ?? null).toBeNull(); + expect(got?.eventSubscriptions).toEqual(["com.ritense.valtimo.document.created"]); + }); + + it("deletes and reports whether a row was removed", async () => { + await repo.set("cfg-1", config()); + expect(await repo.delete("cfg-1")).toBe(true); + expect(await repo.delete("cfg-1")).toBe(false); + expect(await repo.get("cfg-1")).toBeUndefined(); + }); + + it("lists configurations and filters by plugin id/version", async () => { + await repo.set("a", config({ configurationId: "a", pluginId: "p1", pluginVersion: "1.0.0" })); + await repo.set("b", config({ configurationId: "b", pluginId: "p1", pluginVersion: "2.0.0" })); + await repo.set("c", config({ configurationId: "c", pluginId: "p2", pluginVersion: "1.0.0" })); + + expect(await repo.list()).toHaveLength(3); + const p1v1 = await repo.listByPlugin("p1", "1.0.0"); + expect(p1v1.map((c) => c.configurationId)).toEqual(["a"]); + }); + + it("survives a simulated restart — a fresh registry reads persisted configs (boot reload)", async () => { + await repo.set("cfg-1", config()); + + // A new repository/registry over a new pool models the host restarting and rehydrating from pg. + const freshPool = await createDbPool( + { + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }, + noopLogger() + ); + try { + const registry = new ConfigRegistry(new ConfigRepository(freshPool)); + const all = await registry.list(); + expect(all).toHaveLength(1); + expect(all[0].eventBroker?.amqpUrl).toBe("amqp://broker"); + } finally { + await closeDbPool(freshPool); + } + }); +}); diff --git a/plugin-host/app/test/integration/event-consumer.int.test.ts b/plugin-host/app/test/integration/event-consumer.int.test.ts new file mode 100644 index 0000000000..223140ecbb --- /dev/null +++ b/plugin-host/app/test/integration/event-consumer.int.test.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {RabbitMQContainer, type StartedRabbitMQContainer} from "@testcontainers/rabbitmq"; +import * as amqp from "amqplib"; +import {afterAll, afterEach, beforeAll, describe, expect, it, vi} from "vitest"; +import {EventConsumerManager} from "../../src/rabbitmq/event-consumer.js"; +import type {EventBrokerConfig, HostLogger, PluginConfiguration} from "../../src/models/index.js"; + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +describe("EventConsumerManager against real RabbitMQ", () => { + let container: StartedRabbitMQContainer; + let amqpUrl: string; + const managers: EventConsumerManager[] = []; + + beforeAll(async () => { + container = await new RabbitMQContainer("rabbitmq:3.13-management-alpine").start(); + amqpUrl = container.getAmqpUrl(); + }); + + afterAll(async () => { + if (container) await container.stop(); + }); + + afterEach(async () => { + await Promise.all(managers.splice(0).map((m) => m.close())); + }); + + function broker(exchange: string): EventBrokerConfig { + return { amqpUrl, exchange, exchangeType: "fanout", queueMode: "live" }; + } + + function config(exchange: string, overrides: Partial = {}): PluginConfiguration { + return { + configurationId: "cfg", + pluginId: "case-summary", + pluginVersion: "0.1.0", + properties: {}, + serviceToken: "svc", + gzacBaseUrl: "http://gzac:8080", + eventSubscriptions: ["test.type"], + eventBroker: broker(exchange), + ...overrides, + }; + } + + function makeManager(hostId: string, configs: PluginConfiguration[]) { + const callEvent = vi.fn(async () => ({ status: "completed" })); + const configRegistry = { list: async () => configs } as never; + const pluginManager = { callEvent } as never; + const manager = new EventConsumerManager(pluginManager, configRegistry, hostId, noopLogger()); + managers.push(manager); + return { manager, callEvent }; + } + + function cloudEvent(type: string) { + return { + id: "evt-1", + source: "gzac", + type, + time: "2026-07-10T12:00:00Z", + data: { userId: "alice", roles: ["ROLE_USER"], resultType: "document", resultId: "d1", result: { x: 1 } }, + }; + } + + async function publish(exchange: string, event: unknown): Promise { + const conn = await amqp.connect(amqpUrl); + try { + const ch = await conn.createConfirmChannel(); + await ch.assertExchange(exchange, "fanout", { durable: true }); + ch.publish(exchange, "", Buffer.from(JSON.stringify(event))); + await ch.waitForConfirms(); + await ch.close(); + } finally { + await conn.close(); + } + } + + it("delivers a subscribed event through the broker to handle_event", async () => { + const exchange = "evt-delivery"; + const { manager, callEvent } = makeManager("host-1", [config(exchange)]); + await manager.sync(); + + await publish(exchange, cloudEvent("test.type")); + + await vi.waitFor(() => expect(callEvent).toHaveBeenCalledTimes(1), { timeout: 15_000, interval: 200 }); + const event = callEvent.mock.calls[0][2].event; + expect(event).toMatchObject({ type: "test.type", id: "evt-1", userId: "alice", resultId: "d1" }); + }); + + it("does not deliver an event type outside the granted subscription set", async () => { + const exchange = "evt-gate"; + const { manager, callEvent } = makeManager("host-1", [config(exchange, { eventSubscriptions: ["only.this"] })]); + await manager.sync(); + + await publish(exchange, cloudEvent("test.type")); // not granted + // Give the broker a moment; the ungranted type must never reach the handler. + await new Promise((r) => setTimeout(r, 1500)); + expect(callEvent).not.toHaveBeenCalled(); + + // Sanity: a granted type on the same consumer still lands, proving the consumer is live. + await publish(exchange, cloudEvent("only.this")); + await vi.waitFor(() => expect(callEvent).toHaveBeenCalledTimes(1), { timeout: 15_000, interval: 200 }); + }); + + it("load-balances across replicas that share a HOST_ID (competing consumers → once)", async () => { + const exchange = "evt-competing"; + const a = makeManager("host-shared", [config(exchange, { configurationId: "a" })]); + const b = makeManager("host-shared", [config(exchange, { configurationId: "b" })]); + await a.manager.sync(); + await b.manager.sync(); + + await publish(exchange, cloudEvent("test.type")); + + // Exactly one replica handles the event (they bind the same queue). + await vi.waitFor( + () => expect(a.callEvent.mock.calls.length + b.callEvent.mock.calls.length).toBe(1), + { timeout: 15_000, interval: 200 } + ); + // Hold to ensure the other replica does not also pick it up. + await new Promise((r) => setTimeout(r, 1000)); + expect(a.callEvent.mock.calls.length + b.callEvent.mock.calls.length).toBe(1); + }); + + it("fans out to distinct hosts (each HOST_ID gets its own copy)", async () => { + const exchange = "evt-fanout"; + const a = makeManager("host-a", [config(exchange, { configurationId: "a" })]); + const b = makeManager("host-b", [config(exchange, { configurationId: "b" })]); + await a.manager.sync(); + await b.manager.sync(); + + await publish(exchange, cloudEvent("test.type")); + + await vi.waitFor(() => { + expect(a.callEvent).toHaveBeenCalledTimes(1); + expect(b.callEvent).toHaveBeenCalledTimes(1); + }, { timeout: 15_000, interval: 200 }); + }); + + it("self-heals after the broker drops the connection (reconnect + resume delivery)", async () => { + const exchange = "evt-reconnect"; + const { manager, callEvent } = makeManager("host-recon", [config(exchange)]); + await manager.sync(); + + await publish(exchange, cloudEvent("test.type")); + await vi.waitFor(() => expect(callEvent).toHaveBeenCalledTimes(1), { timeout: 15_000, interval: 200 }); + + // Force the broker to drop every client connection — the consumer must reconnect on its own. + await container.exec(["rabbitmqctl", "close_all_connections", "integration-test-reconnect"]); + + // After reconnect the live queue is re-created; events published during the gap are lost, so + // re-publish on each poll until one is delivered post-reconnect. + await vi.waitFor( + async () => { + await publish(exchange, cloudEvent("test.type")); + expect(callEvent.mock.calls.length).toBeGreaterThan(1); + }, + { timeout: 30_000, interval: 1_000 } + ); + }); +}); diff --git a/plugin-host/app/test/wasm/fixture.ts b/plugin-host/app/test/wasm/fixture.ts new file mode 100644 index 0000000000..967254b062 --- /dev/null +++ b/plugin-host/app/test/wasm/fixture.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {dirname, join, resolve} from "node:path"; +import {fileURLToPath} from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); // /test/wasm + +/** plugin-host root (…/plugin-host). */ +export const PLUGIN_HOST_ROOT = resolve(here, "..", "..", ".."); + +/** The fixture plugin directory and its compiled Wasm module + manifest. */ +export const FIXTURE_DIR = join(PLUGIN_HOST_ROOT, "test-fixtures", "test-plugin"); +export const FIXTURE_WASM = join(FIXTURE_DIR, "dist", "plugin.wasm"); +export const FIXTURE_MANIFEST = join(FIXTURE_DIR, "manifest.json"); +export const FIXTURE_PLUGIN_ID = "test-plugin"; +export const FIXTURE_VERSION = "1.0.0"; + +/** The extism-js compiler location the build tooling and CI both use. */ +export const EXTISM_JS_BIN = join(PLUGIN_HOST_ROOT, ".bin", "extism-js"); + +export const NODE_MAJOR = Number(process.versions.node.split(".")[0]); diff --git a/plugin-host/app/test/wasm/global-setup.ts b/plugin-host/app/test/wasm/global-setup.ts new file mode 100644 index 0000000000..efd690e49c --- /dev/null +++ b/plugin-host/app/test/wasm/global-setup.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {execFileSync} from "node:child_process"; +import {chmodSync, existsSync} from "node:fs"; +import {dirname, join} from "node:path"; +import {EXTISM_JS_BIN, FIXTURE_DIR, FIXTURE_WASM} from "./fixture.js"; + +/** + * Compiles the fixture plugin to Wasm before the L3 suite runs, using the real SDK toolchain + * (`valtimo-plugin-build` → esbuild → extism-js). The build tooling finds extism-js on PATH, so the + * `.bin` dir is prepended. Fails loudly with remediation steps if a prerequisite is missing. + */ +export default async function setup(): Promise { + if (!existsSync(join(FIXTURE_DIR, "node_modules"))) { + throw new Error( + `Fixture dependencies are not installed.\n Run: (cd ${FIXTURE_DIR} && npm install)` + ); + } + + const env = { ...process.env }; + if (existsSync(EXTISM_JS_BIN)) { + try { + chmodSync(EXTISM_JS_BIN, 0o755); + } catch { + // best-effort; a read-only mount would keep whatever mode is already set + } + env.PATH = `${dirname(EXTISM_JS_BIN)}:${env.PATH ?? ""}`; + } + + try { + execFileSync("npm", ["run", "build"], { cwd: FIXTURE_DIR, env, stdio: "pipe" }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "Failed to build the fixture Wasm plugin.\n" + + ` extism-js expected at: ${EXTISM_JS_BIN} (or on PATH)\n` + + " Ensure the SDK is built (cd plugin-host/plugin-sdk && npm run build).\n" + + ` Underlying error: ${detail}` + ); + } + + if (!existsSync(FIXTURE_WASM)) { + throw new Error(`Fixture build reported success but produced no Wasm at ${FIXTURE_WASM}`); + } +} diff --git a/plugin-host/app/test/wasm/plugin-manager.wasm.test.ts b/plugin-host/app/test/wasm/plugin-manager.wasm.test.ts new file mode 100644 index 0000000000..523b4de3d2 --- /dev/null +++ b/plugin-host/app/test/wasm/plugin-manager.wasm.test.ts @@ -0,0 +1,227 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {cpSync, mkdirSync, mkdtempSync, rmSync} from "node:fs"; +import {createServer, type Server} from "node:http"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {afterAll, beforeAll, describe, expect, it} from "vitest"; +import type {HostLogger} from "../../src/models/index.js"; +import {PluginManager} from "../../src/plugin-manager.js"; +import {FIXTURE_MANIFEST, FIXTURE_PLUGIN_ID, FIXTURE_VERSION, FIXTURE_WASM, NODE_MAJOR,} from "./fixture.js"; + +function noopLogger(): HostLogger { + const l: HostLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => l, + }; + return l; +} + +/** A throwaway HTTP server standing in for the GZAC instance the gzac_api callback targets. */ +function startGzacStub(): Promise<{ server: Server; baseUrl: string; lastAuth: () => string | undefined }> { + let lastAuth: string | undefined; + const server = createServer((req, res) => { + lastAuth = req.headers["authorization"] as string | undefined; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ echoedPath: req.url })); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ server, baseUrl: `http://127.0.0.1:${port}`, lastAuth: () => lastAuth }); + }); + }); +} + +/** + * Stubs for the manager's persistence collaborators. The config provider grants every capability + * (and no endpoint list — "older push", so gzac_api is not allowlist-restricted) so the fixture's + * handlers can exercise the host functions. + */ +const configProviderStub = { + get: async (configurationId: string) => ({ + configurationId, + pluginId: FIXTURE_PLUGIN_ID, + pluginVersion: FIXTURE_VERSION, + properties: {}, + serviceToken: "svc-token-123", + gzacBaseUrl: "http://gzac.invalid", + eventSubscriptions: [], + grantedCapabilities: ["gzac_api", "http_request", "kv", "log"], + }), +}; + +const kvStore = new Map(); +const kvRepositoryStub = { + get: async (configId: string, key: string) => { + const found = kvStore.has(`${configId}:${key}`); + return { found, value: found ? kvStore.get(`${configId}:${key}`) : undefined }; + }, + set: async (configId: string, key: string, value: unknown) => { + kvStore.set(`${configId}:${key}`, value); + }, + delete: async (configId: string, key: string) => kvStore.delete(`${configId}:${key}`), + list: async () => [] as string[], +}; + +const logRepositoryStub = { + insert: async () => {}, +}; + +// Extism `runInWorker: true` (which PluginManager hardcodes for async host functions) needs Node 22. +describe.skipIf(NODE_MAJOR < 22)("PluginManager on compiled Wasm (runInWorker)", () => { + let storageDir: string; + let manager: PluginManager; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), "plugin-host-storage-")); + const pluginDir = join(storageDir, FIXTURE_PLUGIN_ID, FIXTURE_VERSION); + mkdirSync(pluginDir, { recursive: true }); + cpSync(FIXTURE_WASM, join(pluginDir, "plugin.wasm")); + cpSync(FIXTURE_MANIFEST, join(pluginDir, "manifest.json")); + + manager = new PluginManager( + storageDir, + noopLogger(), + configProviderStub as never, + kvRepositoryStub as never, + logRepositoryStub as never + ); + await manager.loadPlugin(FIXTURE_PLUGIN_ID, FIXTURE_VERSION); + }); + + afterAll(async () => { + await manager?.unloadPlugin(FIXTURE_PLUGIN_ID, FIXTURE_VERSION); + await manager?.close(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + const actionCall = (overrides: Record = {}) => ({ + configurationId: "cfg-1", + configuration: { greeting: "hi" }, + processInstanceId: "pi", + documentId: "doc", + activityId: "act", + properties: {}, + serviceToken: "svc-token-123", + gzacBaseUrl: "http://gzac.invalid", + ...overrides, + }); + + it("runs an action and returns its variables", async () => { + const out = await manager.callAction(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, "echo", actionCall()); + expect(out.status).toBe("completed"); + expect((out.variables as { configFromAccessor: unknown }).configFromAccessor).toEqual({ greeting: "hi" }); + }); + + it("does not serialize the service token / gzacBaseUrl into the Wasm input (host-context secrecy)", async () => { + const out = await manager.callAction(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, "echo", actionCall()); + const variables = out.variables as { inputKeys: string[]; input: Record }; + expect(variables.inputKeys).not.toContain("serviceToken"); + expect(variables.inputKeys).not.toContain("gzacBaseUrl"); + expect(variables.input).not.toHaveProperty("serviceToken"); + expect(variables.input).not.toHaveProperty("gzacBaseUrl"); + }); + + it("threads the service token through the host context to the gzac_api callback", async () => { + const gzac = await startGzacStub(); + try { + const out = await manager.callAction( + FIXTURE_PLUGIN_ID, + FIXTURE_VERSION, + "call-gzac", + actionCall({ serviceToken: "svc-token-123", gzacBaseUrl: gzac.baseUrl }) + ); + expect(out.status).toBe("completed"); + const variables = out.variables as { gzacStatus: number; gzacBody: { echoedPath: string } }; + expect(variables.gzacStatus).toBe(200); + expect(variables.gzacBody.echoedPath).toBe("/api/v1/echo"); + // The token rode in the per-call host context and was attached by the host function, never by + // the plugin (which cannot see it). + expect(gzac.lastAuth()).toBe("Bearer svc-token-123"); + } finally { + gzac.server.close(); + } + }); + + it("delivers an event to handle_event", async () => { + const out = await manager.callEvent(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, { + configurationId: "cfg-1", + configuration: {}, + event: { type: "test.event.handled", id: "e", source: "s" }, + serviceToken: "svc-token-123", + gzacBaseUrl: "http://gzac.invalid", + }); + expect(out.status).toBe("completed"); + }); + + it("serves a data request via handle_request", async () => { + const out = await manager.callRequest(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, { + configurationId: "cfg-1", + configuration: { c: 1 }, + method: "GET", + path: "/echo", + query: { a: "b" }, + userToken: "user-token", + }); + expect(out.status).toBe(200); + expect(out.body).toMatchObject({ path: "/echo", method: "GET", query: { a: "b" } }); + }); + + it("serializes concurrent calls to one instance without an Extism reentrancy error", async () => { + // Without runExclusive these would hit "plugin is not reentrant"; with it they queue and each + // returns its own echoed documentId. + const calls = Array.from({ length: 8 }, (_, i) => + manager.callAction(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, "echo", actionCall({ documentId: `doc-${i}` })) + ); + const results = await Promise.all(calls); + const seenDocIds = results.map((r) => (r.variables as { input: { documentId: string } }).input.documentId); + expect(results.every((r) => r.status === "completed")).toBe(true); + expect(new Set(seenDocIds)).toEqual(new Set(Array.from({ length: 8 }, (_, i) => `doc-${i}`))); + }); + + it("throws for an unknown plugin/version", async () => { + await expect(manager.callAction("ghost", "9.9.9", "echo", actionCall())).rejects.toThrow(/not found/i); + }); + + it("cancels a stuck plugin call at wasmTimeoutMs and recovers on the next call", async () => { + // A dedicated manager with a short timeout so the spinning fixture handler is cancelled fast. + const timeoutManager = new PluginManager( + storageDir, + noopLogger(), + configProviderStub as never, + kvRepositoryStub as never, + logRepositoryStub as never, + { wasmTimeoutMs: 1_000 } + ); + try { + await timeoutManager.loadPlugin(FIXTURE_PLUGIN_ID, FIXTURE_VERSION); + await expect( + timeoutManager.callAction(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, "spin", actionCall()) + ).rejects.toThrow(/timed out after 1000ms/); + // The stale instance was dropped; a fresh call works again. + const out = await timeoutManager.callAction(FIXTURE_PLUGIN_ID, FIXTURE_VERSION, "echo", actionCall()); + expect(out.status).toBe("completed"); + } finally { + await timeoutManager.close(); + } + }, 30_000); +}); diff --git a/plugin-host/app/test/wasm/sdk-runtime.wasm.test.ts b/plugin-host/app/test/wasm/sdk-runtime.wasm.test.ts new file mode 100644 index 0000000000..e1706f4d7f --- /dev/null +++ b/plugin-host/app/test/wasm/sdk-runtime.wasm.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createPlugin, {type Plugin} from "@extism/extism"; +import {afterAll, beforeAll, describe, expect, it} from "vitest"; +import {FIXTURE_WASM} from "./fixture.js"; + +/** + * L3 — the SDK runtime dispatcher (`runtime.ts`) exercised through a real Extism/QuickJS module. + * The fixture is compiled with the actual SDK build toolchain, so these assertions reflect what a + * shipped plugin does — including behaviours (QuickJS promise settling) that cannot be reproduced in + * plain Node. No worker is used; a stub `gzac_api` merely satisfies the module's host import. + */ +describe("SDK runtime dispatch (compiled Wasm)", () => { + let plugin: Plugin; + + beforeAll(async () => { + // Stub every host import the SDK bundle declares (the module fails to instantiate if one is + // missing); only gzac_api returns a meaningful reply, the rest just satisfy the import. + const okReply = (body: unknown) => (cc: { store: (s: string) => bigint }) => + cc.store(JSON.stringify(body)); + plugin = await createPlugin(FIXTURE_WASM, { + useWasi: true, + functions: { + "extism:host/user": { + gzac_api: okReply({ status: 200, headers: {}, body: {} }), + http_request: okReply({ status: 200, headers: {}, body: {} }), + kv: okReply({ status: 200 }), + log: okReply({ status: 200 }), + }, + }, + }); + }); + + afterAll(async () => { + await plugin?.close(); + }); + + async function call(fn: string, input: unknown): Promise> { + const out = await plugin.call(fn, JSON.stringify(input)); + return JSON.parse(out!.text()); + } + + const actionInput = (actionKey: string, extra: Record = {}) => ({ + actionKey, + configurationId: "cfg-1", + configuration: { greeting: "hi" }, + processInstanceId: "pi", + documentId: "doc", + activityId: "act", + properties: {}, + ...extra, + }); + + describe("handle_action", () => { + it("dispatches to the registered handler and exposes the config accessor", async () => { + const out = await call("handle_action", actionInput("echo")); + expect(out.status).toBe("completed"); + const variables = out.variables as Record; + expect(variables.configFromAccessor).toEqual({ greeting: "hi" }); + }); + + it("only sees the ActionInput fields — no host-only secrets leak into the Wasm input", async () => { + // The Wasm input is exactly what the SDK dispatched to the handler. Even if a caller added + // extra keys, the shape a plugin receives must be the declared ActionInput and nothing more. + const out = await call("handle_action", actionInput("echo")); + const variables = out.variables as { inputKeys: string[] }; + expect(variables.inputKeys).toEqual([ + "actionKey", + "activityId", + "configuration", + "configurationId", + "documentId", + "processInstanceId", + "properties", + ]); + expect(variables.inputKeys).not.toContain("serviceToken"); + expect(variables.inputKeys).not.toContain("gzacBaseUrl"); + }); + + it("returns UNKNOWN_ACTION for an unregistered key", async () => { + const out = await call("handle_action", actionInput("does-not-exist")); + expect(out).toMatchObject({ status: "error", errorCode: "UNKNOWN_ACTION" }); + }); + + it("wraps a thrown error in an EXECUTION_ERROR envelope with the message", async () => { + const out = await call("handle_action", actionInput("boom")); + expect(out).toMatchObject({ status: "error", errorCode: "EXECUTION_ERROR", errorMessage: "intentional boom" }); + }); + + // KNOWN LIMITATION (verified here, not assumed): under the Extism JS PDK (QuickJS-ng) an awaited + // promise does NOT settle synchronously, so a handler that performs a real `await` cannot be + // supported by the current `settleSync` and fails. Plugins must use the synchronous `gzacApi.*` + // (the host suspends the call) rather than `async`/`await` on JS promises. This assertion pins + // that behaviour; if the runtime gains real async support it should flip to a success. + it("does NOT settle a genuinely-async handler (QuickJS has no event loop)", async () => { + const out = await call("handle_action", actionInput("async-double", { properties: { value: 21 } })); + expect(out).toMatchObject({ status: "error", errorCode: "EXECUTION_ERROR" }); + expect(out.errorMessage).toMatch(/did not settle synchronously/); + }); + }); + + describe("handle_event", () => { + it("reports completed for a handled event type", async () => { + const out = await call("handle_event", { type: "test.event.handled", id: "e", source: "s", configuration: {} }); + expect(out).toEqual({ status: "completed" }); + }); + + it("reports ignored for an unhandled event type", async () => { + const out = await call("handle_event", { type: "other.type", id: "e", source: "s", configuration: {} }); + expect(out).toEqual({ status: "ignored" }); + }); + }); + + describe("handle_request", () => { + it("routes to the registered path handler and echoes the request", async () => { + const out = await call("handle_request", { + method: "GET", + path: "/echo", + query: { a: "b" }, + configuration: { c: 1 }, + }); + expect(out.status).toBe(200); + expect(out.body).toEqual({ path: "/echo", method: "GET", query: { a: "b" }, configuration: { c: 1 } }); + }); + + it("returns a 404-shaped output for an unregistered path", async () => { + const out = await call("handle_request", { method: "GET", path: "/nope", configuration: {} }); + expect(out.status).toBe(404); + }); + }); +}); diff --git a/plugin-host/app/tsconfig.json b/plugin-host/app/tsconfig.json new file mode 100644 index 0000000000..650058d597 --- /dev/null +++ b/plugin-host/app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/test-support/**", "vitest.config.ts"] +} diff --git a/plugin-host/app/vitest.config.ts b/plugin-host/app/vitest.config.ts new file mode 100644 index 0000000000..ed91f74572 --- /dev/null +++ b/plugin-host/app/vitest.config.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {defineConfig} from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + // Exclude tests, generated output, and thin type/wiring modules that carry no logic. + exclude: [ + "src/**/*.test.ts", + "dist/**", + "src/test-support/**", + // Type/re-export-only modules (app-config keeps real zod logic and stays counted). + "src/models/index.ts", + "src/models/host-logger.ts", + "src/models/plugin-configuration.ts", + "src/models/plugin-manifest.ts", + // Bootstrap wiring; buildHttpsOptions was extracted to https-options.ts for testing. + "src/index.ts", + ], + reporter: ["text", "html"], + }, + }, +}); diff --git a/plugin-host/app/vitest.int.config.ts b/plugin-host/app/vitest.int.config.ts new file mode 100644 index 0000000000..7cb4a49d3b --- /dev/null +++ b/plugin-host/app/vitest.int.config.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {defineConfig} from "vitest/config"; + +/** + * L4 (integration) test config. Spins up real Postgres and RabbitMQ via Testcontainers, so it + * requires a running Docker daemon. Kept separate from `npm test` — run with `npm run test:int`. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["test/integration/**/*.test.ts"], + // Pulling images + booting containers is slow; run integration files one at a time so we don't + // hold several containers open at once. + fileParallelism: false, + hookTimeout: 180_000, + testTimeout: 60_000, + }, +}); diff --git a/plugin-host/app/vitest.wasm.config.ts b/plugin-host/app/vitest.wasm.config.ts new file mode 100644 index 0000000000..1bb3fa2cb7 --- /dev/null +++ b/plugin-host/app/vitest.wasm.config.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {defineConfig} from "vitest/config"; + +/** + * L3 (Wasm/Extism) test config — kept separate from the fast unit config so `npm test` never + * depends on the extism-js toolchain. `globalSetup` compiles the fixture plugin to Wasm first. + * + * Run with `npm run test:wasm`. Requires Node >= 22 (Extism `runInWorker`) for the PluginManager + * suite; the raw-Extism dispatch suite works on any supported Node. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["test/wasm/**/*.test.ts"], + globalSetup: ["test/wasm/global-setup.ts"], + // Compiling the fixture + spawning Extism workers is slower than a unit test. + hookTimeout: 120_000, + testTimeout: 30_000, + }, +}); diff --git a/plugin-host/docs/external-plugin-system-plan.md b/plugin-host/docs/external-plugin-system-plan.md new file mode 100644 index 0000000000..5f368e3444 --- /dev/null +++ b/plugin-host/docs/external-plugin-system-plan.md @@ -0,0 +1,2464 @@ +# External Plugin System — Plan + +External plugins extend the platform with sandboxed JS/TS backend logic and iframe-based +frontends without rebuilding the core app. A **definition** is a `pluginId@version` discovered on +a host; a definition may have multiple **configurations**, each with its own encrypted properties, +granted permissions, and a per-configuration service token. Hosted plugins run as `.wasm` modules +in the plugin host's Extism sandbox. Plugins can run **actions** (synchronous, invoked from a +process service task) and react to **events** (asynchronous, delivered from the core app's event +stream). + +Naming: prose says "core app" / "GZAC instance" / "host"; code identifiers keep their literal +names (`gzac`, `valtimo.*` properties, `external_plugin_*` tables, the `external_plugin_service` +token type). + +Status legend: ✅ implemented & verified · 🟡 implemented, POC-level · ⛔ not implemented. + +## 1. Components + +> **Host capabilities** are a per-plugin allowlist of host-side abilities a plugin may use. Every +> host function — including `gzac_api` — requires an explicit grant. A plugin declares the +> capabilities it needs in `manifest.permissions.capabilities`; the admin accepts each one +> individually during configuration. The host enforces the allowlist at call time — a plugin +> that was not granted a capability gets an error response, never silent access. +> Five capability names are declarable: `gzac_api`, `http_request`, `kv`, and `log` are host +> functions; `frontend_data` gates the host's public plugin-data route (§13.5). + +| Area | Path | Status | +|------|------|--------| +| Core-app backend module | `backend/external-plugin/` | ✅ | +| Endpoint descriptions (`@EndpointDescription` on every controller method) + contract annotation | `backend/*/.../web/rest/*Resource.{kt,java}`, `com.ritense.valtimo.contract.endpoint.EndpointDescription` | ✅ | +| Plugin host (Node + Fastify + Extism, multi-version) | `plugin-host/app/` | 🟡 | +| Host capabilities (`gzac_api`, `http_request`, `kv`, `log`) — capability allowlist enforcement, host functions, persistent storage (KV + logs), admin log view | `plugin-host/app/src/host-functions/{gzac-api,http-request,kv,log}.ts`, `plugin-host/app/src/db/{log-repository,kv-repository}.ts`, `plugin-host/app/src/routes/plugin-logs.ts` | ✅ | +| Event consumer (RabbitMQ → `handle_event`) | `plugin-host/app/src/rabbitmq/event-consumer.ts` | ✅ | +| Backend plugin SDK (`@valtimo/plugin-sdk`) — actions, events, requests (`handle_request`), `gzacApi` (+ `asUser`), `httpRequest`, `kv`, `log` (structured), frontend `t()` + parent-proxy data access (`callValtimo`/`getPluginData`) | `plugin-host/plugin-sdk/` | ✅ | +| Shared manifest validation (name/description-in-translations), one rule set for pack + host | `plugin-host/plugin-sdk/src/manifest-validation.ts` (subpath `@valtimo/plugin-sdk/manifest-validation`) | ✅ | +| Sample plugin (action + event handler + logo + i18n) | `plugin-host/sample-plugins/case-summary/` | ✅ | +| Frontend management UI + external models/service/iframe | `frontend/projects/valtimo/{plugin-management,plugin}/` | ✅ | +| Process-link (`SERVICE_TASK_START`) — FIXED + BUILDING_BLOCK references, action result write-back | `backend/external-plugin/.../processlink/` + frontend process-link | ✅ | +| Building-block support (shared `PluginConfigurationReference`, namespaced config mappings, required-plugins endpoint, BB-context admin UX) | `backend/external-plugin/.../processlink/ExternalPluginServiceTaskStartListener.kt` + `backend/building-block/.../service/BuildingBlockPluginDefinitionService.kt` ↔ frontend `process-link/.../{select-plugin-configuration,configure-building-block-plugins}` (§19) | ✅ | +| Case-definition import/export parity (preview contributor, mapper remap hook, dangling repair, `EXTERNAL_PLUGIN` case-tab import) | `backend/external-plugin/.../{preview/ExternalPluginImportPreviewContributor,service/ExternalPluginConfigurationMappingResolver}.kt`, `backend/case/.../service/CaseTabImporter.kt` ↔ frontend `case-management/.../{case-management-upload,case-management-missing-plugin-configurations}` (§20) | ✅ | +| Action result write-back (`action_result_mappings` + `result` output channel), embedded **and** external | `backend/plugin/.../service/PluginActionResultHandler.kt` ↔ frontend `process-link/.../plugin-action-result-mappings` (§21) | ✅ | +| Per-host broker / callback config + defaults endpoint | `backend/external-plugin/.../web/rest/ExternalPluginManagementResource.kt#hostDefaults` | ✅ | +| Per-host durable event queue mode + TTL (live/durable, `x-expires`) + narrow PATCH endpoint | `backend/external-plugin/.../domain/EventQueueMode.kt`, `service/ExternalPluginHostService.updateEventQueue`, `web/rest/...#updateHostEventQueue` ↔ `plugin-host/app/src/rabbitmq/event-consumer.ts` | ✅ | +| Plugin assets (logo + i18n bundle in manifest, served by host) | `plugin-host/plugin-sdk/bin/valtimo-plugin-pack.mjs`, `plugin-host/app/src/routes/plugin-bundles.ts` | ✅ | +| GZAC→host auth on **every** route (HMAC-SHA256, replay-protected, body-bound): actions, config-push, management | `client/ExternalPluginHostClient.kt` + `security/ExternalPluginHmacSigner.kt` ↔ `plugin-host/app/src/security/{hmac,hmac-auth}.ts`, `routes/{plugin-actions,host-configurations,host-management}.ts` | ✅ | +| Transport confidentiality (TLS): host serves HTTPS from `TLS_*`; broker credentials confined to a confidential transport at host registration | `plugin-host/app/src/index.ts` (`buildHttpsOptions`) + `models/app-config.ts` ↔ `service/ExternalPluginHostService.isSecureTransport` | ✅ | +| GZAC compatibility check (semver range vs running version): comparator + version provider + zip manifest peek; non-blocking UI warnings, upload confirm-gate | `backend/external-plugin/.../compatibility/*` + `web/rest/ExternalPluginManagementResource.kt#uploadPlugin` ↔ frontend `plugin-management/.../utils/external-plugin-compatibility.util.ts` | ✅ | +| Strict delete guards (embedded + external), shared usage DTO/resolver + `/usages` endpoints + read-only in-use modal, no force override | core `backend/plugin/.../{web/rest/dto/PluginUsageDto, service/ProcessDefinitionUsageMetaResolver, service/PluginConfigurationUsageResolver, exception/PluginConfigurationInUseException}` + `backend/external-plugin/.../{service/ExternalPluginHostUsageResolver, exception/ExternalPlugin*InUseException}` ↔ frontend `plugin-management/.../plugin-usage-modal/` | ✅ | +| Iframe case-detail tab (`EXTERNAL_PLUGIN` tab type, side table, PBAC content endpoint, bundle-resolver SPI) + admin UX | `backend/case/.../case_/{domain/tab/CaseExternalPluginTab, repository/CaseExternalPluginTabRepository, service/CaseExternalPluginTabService, rest/CaseExternalPluginTabResource, service/ExternalPluginCaseTabResolver}` + `backend/external-plugin/.../service/ExternalPluginCaseTabResolverImpl` ↔ frontend `case/.../case-detail/tab/external-plugin`, `case-management/.../tabs` | ✅ | +| Downscoped user token (PBAC ∩ allowlist), non-management mint endpoint, parent-proxy iframe (opaque origin, no token in iframe) | `backend/external-plugin/.../security/ExternalPluginUserToken{KeyProvider,Authenticator,Filter}, security/ExternalPluginUserPrincipal, service/ExternalPluginUserTokenService, web/rest/ExternalPluginUserTokenResource` ↔ frontend `plugin/.../external-plugin-iframe`, SDK `frontend/plugin-frontend-sdk.ts` (proxy bridge) | ✅ | +| Plugin-served data route (`handle_request` Wasm export + host `POST .../data`, gated on the granted `frontend_data` capability + per-config rate limit + a required, GZAC-introspected, configuration-bound user token) + backend-as-user (`gzacApi.asUser`) | `plugin-host/plugin-sdk/src/{requests.ts,runtime.ts,gzac-api.ts}`, `plugin-host/app/src/{routes/plugin-data.ts,security/user-token-introspection.ts,host-functions/gzac-api.ts,plugin-manager.ts#callRequest}` ↔ `backend/external-plugin/.../web/rest/ExternalPluginUserTokenIntrospectionResource.kt` | ✅ | + +Single-core-app model with **multiple hosts per instance**: the core app pushes each configuration +directly to its host with a freshly issued service token, a `gzacBaseUrl` callback target taken +from the host row, and an optional `eventBroker` block also taken from the host row. Definitions +are discovered by polling each host (`GET /api/host/plugins`, default 60s) and stored with +`UNIQUE(plugin_id, version)`. + +## 2. Zero-configuration deployment + +The external-plugin module ships with **no additional `application.yml` entries** beyond what the +rest of Valtimo already requires. Every value the module needs is either: + +- **Per-host**, entered once in the add-host UI (host base URL, admin token, callback URL, + optional broker URL/exchange) and stored on the host row, **or** +- **Derived from existing platform config** at runtime (the JWT signing key, the broker exchange + fallback, the legacy callback fallback). + +| What the module needs | Where it comes from | When | +|----------------------|---------------------|------| +| JWT signing keys | `SHA-256(valtimo.plugin.encryption-secret + "\|service")` for service tokens, `SHA-256(… + "\|user")` for user tokens | At every JWT issue/verify. The hash gives a stable 32-byte HmacSHA256 key regardless of the encryption secret's raw length, so AES-128 (16-byte) and AES-256 (32-byte) deployments both work without reconfiguration; hashing also keeps the signing keys cryptographically separate from the AES key. The domain suffix gives each token kind its **own** key, so a token of one kind can never validate against the other kind's parser (§3.2, §13.3). | +| Host HTTP timeouts | `valtimo.external-plugin.connect-timeout` / `read-timeout` (Spring durations, defaults 2 s / 10 s) | Applied to the shared `RestTemplate` every GZAC→host call uses, so an unreachable or slow host fails fast instead of pinning request threads. | +| `gzacBaseUrl` per push | `external_plugin_host.gzac_callback_base_url` | Set in the add-host UI; default pre-fill is `http://localhost:{server.port}` because the admin's browser URL (often the Angular dev proxy at `:4200` or a reverse proxy in production) is not a reliable signal for the URL plugin hosts should call back on. | +| Broker AMQP URL per push | `external_plugin_host.event_broker_amqp_url` (nullable) | Set in the add-host UI; default pre-fill built from `spring.rabbitmq.*`. Null disables events for hosts under this host (actions still work). A non-null broker URL requires the host base URL to be a confidential transport (HTTPS, or a loopback address for local dev); registration is rejected otherwise so AMQP credentials never travel over plaintext (§3.9). | +| Broker exchange per push | `external_plugin_host.event_broker_exchange`, else `valtimo.outbox.publisher.rabbitmq.exchange` | Set in the add-host UI; default pre-fill from the outbox exchange, which is what GZAC itself publishes to. | +| Broker exchange type | hardcoded `fanout` | Matches the outbox publisher and the exchange declared in `imports/gzac-rabbitmq/definitions.json`. | +| Queue mode per push (`live`/`durable`) | `external_plugin_host.event_queue_mode` (default `LIVE`) | Set in the add-host UI and editable later via `PATCH .../host/{id}/event-queue` (§8.4). Drives the host's `assertQueue` arguments. | +| Queue inactivity TTL per push (ms) | `external_plugin_host.event_queue_ttl_ms` (nullable; required when mode is `DURABLE`) | Validated to `[1h, 30d]`, default 72h. Maps to RabbitMQ `x-expires`; ignored (forced null) in `LIVE` mode. | + +The module requires **no entries** in `backend/app/gzac/src/main/resources/application.yml`. + +## 3. Endpoint-scoped service token & permission enforcement ✅ + +A plugin gets a token scoped to exactly the API endpoints its configuration was granted, enforced +per-request with deny-by-default. The same token authenticates both action callbacks and event +callbacks. + +**3.1 Activation stores grants (`service/ExternalPluginConfigurationService.kt`)** +- `create()` validates properties against the definition JSON schema, then + `validateGrantedEndpointsCoverManifest()` **rejects the configuration unless the granted + endpoints exactly match the manifest's declared set** (`requireExactGrantMatch`): every declared + endpoint must be granted — the admin explicitly acknowledges the plugin's full footprint — and + nothing beyond the declaration can be granted (a grant the plugin never asked for is always a + mistake). A manifest without a declaration section requires an empty grant set — nothing may be + granted. +- `create()` likewise runs `validateGrantedEventsCoverManifest()` — the same exact-match gate + applied to `manifest.eventSubscriptions`. `create()` also runs + `validateGrantedCapabilitiesCoverManifest()` — the same exact-match gate applied to + `manifest.permissions.capabilities`; capability names are additionally parsed into the + `ExternalPluginCapability` enum **before anything is persisted**, so an unknown capability name + is rejected up front. All three — `grantedEndpoints`, `grantedEvents`, and + `grantedCapabilities` — are parameters of `create()`; all three are enforced at + the service layer, not only in the UX (§4). +- Grants persist to `external_plugin_granted_endpoint` (`configuration_id`, `http_method`, + `endpoint_pattern`), `external_plugin_granted_event` (`configuration_id`, `event_type`), and + `external_plugin_granted_capability` (`configuration_id`, `capability`); + `update()` with non-null `grantedEndpoints` replaces the endpoint grants, null leaves them + unchanged. `update()` has **no** `grantedEvents` or `grantedCapabilities` parameter — event + and capability grants cannot change through the edit flow after activation; the one path that + resets them is an admin-confirmed version overwrite, which re-grants every configuration to + exactly the newly declared sets after the admin re-reviewed them (§11). Granted capabilities + are pushed to the host alongside the configuration (§18.3) so the host can enforce the + allowlist at call time. + +**3.2 Token (`service/ExternalPluginServiceTokenService.kt`)** — HS256 JWT: +`sub=external-plugin:{pluginId}:{configId}`, `type=external_plugin_service`, `plugin_config_id`, +`plugin_id`, `plugin_version`, `token_generation` (the configuration's revocation counter — +§3.6), `iss=valtimo-gzac`, `exp=now+ttl`. **No roles.** Signed with +`SHA-256(valtimo.plugin.encryption-secret + "|service")` — the shared +`security/ExternalPluginTokenKeyProvider.kt` base derives a **domain-separated** key per token +kind (`|service` here, `|user` for the iframe token, §13.3), so a token of one kind can never +validate against the other kind's parser — the `type` claim is a routing hint, not the security +boundary. See `security/ExternalPluginServiceTokenKeyProvider.kt`. +The lifetime `ttl` is the `valtimo.external-plugin.service-token.ttl` property — a Spring duration +(ISO-8601 `PT10M` or the `10m` shorthand), defaulting to **10 minutes** — parsed in the +autoconfiguration (`DurationStyle.detectAndParse`) and handed to the service; the service itself +falls back to 10 minutes when constructed without one. The short default costs nothing because the +discovery poll (default 60s) re-pushes a fresh token every cycle (§3.6); it only caps how long a +*leaked* token stays usable. + +**3.3 Recognition (`security/ExternalPluginServiceTokenFilter.kt`)** — registered **before** +`BearerTokenAuthenticationFilter` (`security/ExternalPluginCallbackHttpSecurityConfigurer.kt`, +`@Order(450)`): parses the bearer JWT with the service-token signing key; passes through if +signature or `type` claim don't match (Keycloak tokens untouched); on match the authenticator +first checks the token's `token_generation` claim against the configuration's current counter +(§3.6) — a mismatch, a missing claim, or a configuration that no longer exists rejects the token — +then sets an `ExternalPluginServicePrincipal`, **strips the `Authorization` header**, and runs the +rest of the chain inside `AuthorizationContext.runWithoutAuthorization` (PBAC is intentionally +bypassed for service tokens — the allowlist is the sole gate). The service- and user-token filters share the +`AbstractExternalPluginTokenFilter` base, and all three plugin filters are excluded from servlet +auto-registration (disabled `FilterRegistrationBean`s in the autoconfiguration) so they run only +inside the Spring Security chain, never a second time as bare servlet filters. + +**3.4 Enforcement (`security/ExternalPluginEndpointAllowlistFilter.kt`)** — registered **after** +`BearerTokenAuthenticationFilter`: +1. Principal not `ExternalPluginServicePrincipal` (or `ExternalPluginUserPrincipal`, §13.3) → pass + through (users and existing plugins unaffected). +2. **Hard denylist** (`DENYLIST_PATTERNS`) → 403 **regardless of what was granted**: plugin tokens + can never reach `/api/management/v1/external-plugin/**` and `/api/v1/external-plugin/**` + (external-plugin management incl. host registration, plus user-token minting — a plugin must + not mint tokens for arbitrary users) or `/api/management/v1/roles/**` and + `/api/management/v1/permissions/**` (role/permission management — privilege escalation). One + narrow carve-out precedes the denylist: a **user**-token principal may always `GET + /api/v1/external-plugin/user-token/introspect` (exact path, GET only — the plugin host must be + able to introspect the token before serving `/data`, §13.5; the endpoint is read-only and + returns nothing beyond the token's own claims). Service-token principals get no carve-out. +3. Load grants for `plugin_config_id`, match request via `AntPathRequestMatcher(pattern, method)`; + no match → 403; empty grants → deny. The compiled matchers are cached per configuration id for + a short TTL (30 s) so the per-request cost is a map lookup instead of a DB query; an invalid + stored pattern is skipped with a warning (deny unless another grant matches) rather than + failing the request with a 500. + +**3.5 Host callback** — the host's `gzac_api` host function +(`plugin-host/app/src/host-functions/gzac-api.ts`) attaches the per-config `serviceToken` as +`Authorization: Bearer` to `${gzacBaseUrl}${path}`, forwarding method, JSON body, and headers. The +token is passed via Extism `hostContext`, never serialised into the Wasm input — plugin code never +sees it. This is the same mechanism for both action handlers and event handlers. `gzac_api` is a +**capability** (§18) — the configuration must be granted `gzac_api` in its capability allowlist +or the host function returns a capability-denied error without making the upstream call. The host +additionally enforces the configuration's **granted-endpoint allowlist on its own side** +(`security/endpoint-allowlist.ts`, Ant-style patterns): a call outside the granted set is refused +with a 403-shaped reply before anything leaves the host — GZAC's servlet filter (§3.4) remains +the authoritative gate; a configuration whose push carried no endpoint list is allowed with a +warning (§8.2). A plugin-supplied `Authorization` header is **stripped** (and logged) and the +host-controlled credential is attached last, so a plugin can never substitute its own token. The +callback fetch is bounded by an `AbortSignal` timeout (`GZAC_API_TIMEOUT_MS`, default 60 s — +reported as a 504-shaped reply, with a 502-shaped reply for a failed fetch), so a hung GZAC +endpoint cannot pin the plugin call and its per-plugin lock indefinitely. + +**3.6 Token lifecycle** — operator-tunable TTL (`valtimo.external-plugin.service-token.ttl`, +default 10m, §3.2), **no separate refresh loop**. Each healthy discovery poll re-pushes every +configuration with a freshly issued token +(`service/ExternalPluginDiscoveryService.syncConfigurations()`), continuously replacing tokens +well inside their lifetime. That poll *is* the refresh mechanism (default 60s, +`valtimo.external-plugin.polling.rate`), so a tuned TTL must stay comfortably above the poll +interval or a token can lapse between pushes. The polling job (`service/ExternalPluginDiscoveryJob.kt`) +runs under ShedLock (`@SchedulerLock`, `lockAtLeastFor` 10 s / `lockAtMostFor` 10 min), so in a +multi-replica deployment exactly one instance polls per tick instead of every replica hammering +the same hosts. Discovery keeps a strict transaction discipline: all host HTTP I/O (health probe, +plugin listing, config re-pushes) runs **outside** any database transaction, with the bookkeeping +writes in short per-host transactions (`TransactionTemplate`) — a slow or hanging host never pins +a database connection, and one host's failure never rolls back another host's bookkeeping. + +**Revocation.** Every configuration carries a revocation counter +(`external_plugin_configuration.token_generation`); every token minted for it — service (§3.2) +*and* user (§13.3) — is stamped with that counter as its `token_generation` claim, and both token +authenticators accept a token only while its claim equals the configuration's current value (a +missing claim or a deleted configuration also rejects). `POST +/api/management/v1/external-plugin/configuration/{id}/revoke-tokens` (ADMIN) bumps the counter and +immediately re-pushes the configuration, handing the host a fresh token of the new generation: a +leaked or hoarded token dies on its next use while a legitimate host keeps working without waiting +for the next poll. Because the host's user-token introspection route authenticates with the token +under introspection (§13.3), a revoked user token also stops validating for the host's `/data` +path. Deleting the configuration is the other, heavier kill switch (§12). + +**3.7 Caveat** — service tokens bypass PBAC, so the allowlist is the entire authorization surface; +an over-broad grant (`/api/v1/**`) gives broad role-free access. Hence the activation-time +acceptance screen (§4) is security-critical. + +**3.8 Manifest field naming.** The endpoint allowlist lives at `permissions.endpoints` in the +manifest. The same declaration is the source of truth for both the service-token allowlist (this +section) and the iframe user-token path (§13, ✅) — one block, **two principals** through one +`ExternalPluginEndpointAllowlistFilter` (`ExternalPluginServicePrincipal` and +`ExternalPluginUserPrincipal`). SDK type `Endpoint`, Kotlin DTO `GrantedEndpointEntry`, frontend type +`ExternalPluginEndpoint`. The capability allowlist lives at `permissions.capabilities` in the +manifest — a string array of capability names (any of `gzac_api`, `http_request`, `kv`, `log`, +`frontend_data`). +SDK type `string[]`, Kotlin `List`, frontend `string[]`. See §18 for the full capability +system. + +**3.9 Reverse direction — GZAC→host authentication (HMAC), every route ✅.** Calls that flow the +*other* way (core app → host) are authenticated with an HMAC-SHA256 signature, not the service +token. Every GZAC→host route is covered: action invocations, config-push, and host management. The +client signs `{METHOD}\n{path}\n{timestamp}\n{bodyHash}` (`bodyHash = SHA-256(body)` hex, +`timestamp = Instant.now()` ISO-8601) with the host's **decrypted secret** +(`security/ExternalPluginHmacSigner`), and sends `X-Valtimo-Signature` + `X-Valtimo-Timestamp`. The +HMAC key is therefore the host's admin token (`hostService.decryptedSecret(host)` == the host's +`ADMIN_TOKEN`); the secret is always carried as a signature, never as a bearer token. +`client/ExternalPluginHostClient` signs through one `hmacHeaders(secret, method, path, body)` helper +for every call (`invokeAction`, `invokeSubmit`, `pushConfiguration`, `deleteConfiguration`, +`listPlugins`, `uploadPlugin`, `getConfigurationLogs`). + +The host verifies in a shared Fastify `preHandler` (`createHmacAuthHook`, +`plugin-host/app/src/security/hmac-auth.ts`, delegating to `security/hmac.ts`): headers present, +±5-min timestamp window, timing-safe compare against `computeSignature(ADMIN_TOKEN, …)`. On top of +the timestamp window, a process-wide **seen-signature replay cache** (`security/replay-cache.ts`) +records every accepted signature and rejects a duplicate on side-effecting methods +(POST/PUT/DELETE/PATCH) — closing the replay gap the ±5-min drift window would otherwise leave +open; the signature binds method+path+timestamp+bodyHash, so any legitimate new request differs in +at least the timestamp. The cache is shared by every HMAC-authenticated route, so a signature +accepted by one route can never be replayed against another. The hook is the action route's +`preHandler` and a plugin-level `preHandler` on both `routes/host-configurations.ts` and +`routes/host-management.ts`. + +**Body binding per route shape:** +- **JSON-body routes** (action POST; config-push POST/PUT) opt in to raw-body capture + (`config: { rawBody: true }` + `fastify-raw-body`) and bind the exact request bytes. The + config-push body carries the freshly issued service token and broker credentials — binding it is + what stops a replayed/forged push from installing a swapped token or broker. +- **No-body routes** (config GET/DELETE; management GET/DELETE) bind an empty body + (`SHA-256("")`), so method + path + timestamp are still signed. +- **Multipart upload** (`POST /api/host/plugins`) cannot bind the multipart envelope — RestTemplate + generates the boundary internally, so the client cannot reproduce the wire bytes to hash. Instead + both sides hash the **uploaded file bytes** (the `.zip`). The route is flagged + `config: { deferHmac: true }` so the shared hook skips it, and the handler runs + `verifyDeferredHmac(...)` once it has read the file into a buffer. + +- **Caveat 1 (path prefix):** the signed `path` is the bare route path (`/plugins/...`, + `/api/host/...`); the host verifies `request.url` minus the query string. A reverse proxy that + prepends a path prefix the host sees in `request.url` would break verification. Root-mounted hosts + (the default) are unaffected. +- **Caveat 2 (encryption is the transport's job, not HMAC's):** HMAC authenticates and + integrity-binds every request but does not encrypt it, so confidentiality of the service token and + broker credentials in a config-push body rides on the transport. Two mechanisms keep those secrets + off an eavesdroppable link: + - The host serves **HTTPS** when `TLS_CERT_PATH` + `TLS_KEY_PATH` are set (`buildHttpsOptions` in + `plugin-host/app/src/index.ts`; optional `TLS_CA_PATH` for a chain; both cert and key required or + the host refuses to start), encrypting the GZAC→host channel end-to-end. + - Host registration **refuses a non-null `eventBrokerAmqpUrl` unless the host base URL is a + confidential transport** — HTTPS, or a loopback address (`localhost`/`127.0.0.1`/`::1`) for local + development (`ExternalPluginHostService.isSecureTransport`). Registration is the single gate + because the base URL is immutable afterwards, so no later push can reach an insecure host with + broker credentials. + + Hosts without a broker (actions only) may still run over plain HTTP — e.g. behind a TLS-terminating + reverse proxy. Replay and forgery are closed by the HMAC scheme; eavesdropping is closed by running + the broker-carrying channel over TLS. + +## 4. Permission UX ✅ + +Components: `plugin-management/.../{plugin-external-permissions, plugin-add-modal, +plugin-external-edit-modal, plugin-external-configure}`. Endpoint descriptions are localised via +`POST /api/management/v1/external-plugin/endpoint-descriptions`. Each endpoint declares its own +English and Dutch text directly on the controller handler method with an `@EndpointDescription(en, +nl)` annotation (`com.ritense.valtimo.contract.endpoint.EndpointDescription`); +`EndpointDescriptionService` collects every annotation from Spring's `RequestMappingHandlerMapping` +and resolves a queried pattern against them (glob and `{param}` matching, `en`/`nl` with `en` +fallback). A test (`EndpointDescriptionCoverageTest`, `backend/external-plugin`) enforces that +**every** controller endpoint on the classpath — not only management ones — carries both +translations, so the description requirement cannot drift as endpoints are added. + +The Permissions step shows three read-only sections under a single acknowledgement checkbox: + +- **Host capabilities** — every entry from `manifest.permissions.capabilities` (`gzac_api`, + `http_request`, `kv`, `log`, `frontend_data`). Each capability is shown with a localised name and description + explaining what it grants the plugin (e.g. "GZAC API — Make authenticated calls to the GZAC + REST API on behalf of the plugin or the logged-in user"). Capabilities are displayed first + because they represent the broadest grants. +- **API endpoints** — every entry from `manifest.permissions.endpoints` with method, pattern, and + localised description. Only relevant when `gzac_api` is among the declared capabilities; + otherwise this section is hidden. +- **Events** — every CloudEvent type from `manifest.eventSubscriptions` that the plugin will + receive at `handle_event`. + +All three are equally a permission decision: granting capabilities lets the plugin call host +functions; granting endpoints scopes *which* GZAC endpoints the `gzac_api` capability can reach; +granting events lets it observe domain activity. The single acknowledgement covers all three — each +granted set must **exactly match** the manifest's declared set: the backend rejects activation when +any declared item is missing from the grants *and* when a grant names anything the manifest does +not declare (§3.1). + +- **Add / activate**: select → configure (properties or config iframe) → **Permissions**. Save → + `POST .../configuration` `{definitionId, title, properties, grantedEndpoints, grantedEvents, + grantedCapabilities}`. +- **Edit**: same component with `[readonlyMode]="true"`; the UI update sends `{title, properties}` + only. Granted **events** and **capabilities** cannot change through the edit flow (service-layer + `update()` has no `grantedEvents` or `grantedCapabilities` parameter); the one path that resets + them is the admin-confirmed version overwrite, which re-grants to the newly reviewed declared + sets (§11). Granted **endpoints** are immutable *in the UI*, but the backend `update()` will + replace them if a non-null `grantedEndpoints` is supplied (§3.1) — the immutability of endpoint + grants is a UI guarantee, not a service-layer one. + +## 5. Data model ✅ + +Tables (host secret and config properties stored encrypted via the existing `EncryptionService`). +DDL lives in the **core** module's changelog, not the external-plugin module's own resources: +`backend/core/src/main/resources/config/liquibase/13-28-0/20260504-external-plugin.xml`. + +- `external_plugin_host` — `base_url`, encrypted `secret`, `status`, health/failure counters, + **plus** `gzac_callback_base_url`, `event_broker_amqp_url`, `event_broker_exchange` (all + populated from the add-host UI; the two broker columns nullable for events-off / use-default-exchange), + **plus** `event_queue_mode` (`LIVE`/`DURABLE`, default `LIVE`, added in + `20260617-external-plugin-event-queue.xml`) and `event_queue_ttl_ms` (nullable bigint; required + when mode is `DURABLE`, ignored when `LIVE`). +- `external_plugin_definition` — `UNIQUE(plugin_id, version)`, `config_schema`, `manifest_json`, + `host_id`, `base_url`, `status`, plus `name`, `description`, `provider`, `min_gzac_version` / + `max_gzac_version` (populated at discovery from the manifest's `compatibility` block, compared + against the running GZAC version to surface a non-blocking compatibility warning — §11), + `consecutive_misses`, **plus** `content_hash` / `pending_content_hash` (changeset + `13-32-0/20260806-external-plugin-security-hardening.xml`): the package content hash pinned at + discovery, and — when the host serves different bytes under the same `pluginId@version` — the + hash it serves instead, which flags the definition for admin re-acceptance (§11). The + manifest's declared `eventSubscriptions` live here (inside + `manifest_json`), discovered from the host — but the authoritative subscription list for any + given activated configuration is `external_plugin_granted_event` (next paragraph), not the + manifest copy. +- `external_plugin_configuration` — `definition_id`, `title`, `properties` (encrypted on schema + `x-secret` fields), `created_at`, and `token_generation` (bigint, the revocation counter every + minted token is validated against — §3.6). API responses never carry secret values: + `GET .../configuration/{id}` returns **masked** properties with the `x-secret` fields omitted + entirely (mirroring the embedded module's `PluginConfigurationDto`), and an update whose payload + leaves a secret field absent or blank means "unchanged" — the stored ciphertext is kept and the + stored plaintext is substituted server-side for schema validation, so a round-tripped masked + payload never overwrites a secret. Decrypted properties exist server-side only, for the host + push. +- `external_plugin_granted_event` — `configuration_id`, `event_type`, `granted_at`; + `UNIQUE(configuration_id, event_type)`. Pushed to the host on every config push as the actual + subscription set. A later manifest update that adds a new event type cannot widen this set — the + row only changes when the admin re-grants. +- `external_plugin_granted_endpoint` — `configuration_id`, `http_method`, `endpoint_pattern`, + `granted_at`; `UNIQUE(configuration_id, http_method, endpoint_pattern)`. +- `external_plugin_granted_capability` — `configuration_id`, `capability` (varchar, e.g. + `gzac_api`, `http_request`, `kv`, `log`, `frontend_data`), `granted_at`; + `UNIQUE(configuration_id, capability)`. Pushed to the host on every config push as the + authoritative capability set (§18.3). A later manifest update that adds a new capability cannot + widen this set — the admin must re-grant. +- Each grant table enforces a DB unique constraint on its `(configuration_id, …)` natural key, so + duplicate grant rows are structurally impossible. The replace-on-write `update()` flow deletes a + configuration's endpoint grants and flushes that delete before re-inserting, so a replacement set + that overlaps the previous grants stays within the constraint. +- `external_plugin_*` columns on `process_link` for the `SERVICE_TASK_START` action link: + `external_plugin_config_id` (nullable — null for `BUILDING_BLOCK` references and dangling + imports), `external_plugin_action_key`, `external_plugin_action_properties`. The plugin identity + and version live on the **shared** reference columns `reference_type` / `plugin_definition_key` + (= `pluginId`) / `plugin_definition_version` — the same `PluginConfigurationReference` embeddable + the embedded `PluginProcessLink` maps; embedded rows keep `plugin_definition_version` null + (embedded definitions are unversioned). The reference version is design-time metadata only — + the runtime invocation version always derives from the resolved configuration's definition + (§19). `action_result_mappings` (json, also shared with the embedded link type) holds the + action's result write-back rules (§21). The task-form link's version likewise lives on the + shared reference columns; its own columns are `external_plugin_task_form_{config_id,bundle_key}`. + +Events add **no new table**: subscriptions come from `manifest_json`, the broker connection +details come from the host row, and at push time they are pushed transiently to the host (held +only in the host's in-memory registry until the host stores them in its own PostgreSQL). + +## 6. Adding a host & host-defaults endpoint ✅ + +`GET /api/management/v1/external-plugin/host-defaults` (`ExternalPluginManagementResource`) +returns pre-fills the add-host UI uses to populate the new-host form: + +```json +{ + "gzacCallbackBaseUrl": "http://localhost:8080", + "eventBrokerAmqpUrl": "amqp://***@localhost:5672", + "eventBrokerExchange": "valtimo-events", + "defaultEventQueueTtlMs": 259200000, + "minEventQueueTtlMs": 3600000, + "maxEventQueueTtlMs": 2592000000 +} +``` + +Broker credentials never reach the browser: the AMQP URL's userinfo is **redacted to `***`** in +every API response (`HostResponse.redactAmqpUserInfo` — both here and on stored host rows in +`GET .../host`); the full URL stays server-side. When the redacted default is echoed back on host +registration, `resolveBrokerAmqpUrl` substitutes the real credentials from `spring.rabbitmq.*` +server-side, so a round-tripped redacted URL never ends up stored. + +The operator edits whatever does not match the host's network. URL fields exposed: +`gzacCallbackBaseUrl` is required; `eventBrokerAmqpUrl` and `eventBrokerExchange` are optional. +Leaving the broker URL blank disables events for every configuration under this host. The +`*EventQueueTtlMs` triplet drives the durable-mode TTL input in the UI (default 72h, range 1h–30d); +the constants live on `ExternalPluginHostService` (`DEFAULT_/MIN_/MAX_EVENT_QUEUE_TTL_MS`). + +`ExternalPluginHostService.register()` trims trailing `/` on the URLs, encrypts the secret, +blanks become `null`. When a broker URL is supplied it additionally requires the host base URL to be +a confidential transport (HTTPS, or a loopback address for local development) and rejects the +registration otherwise, so the broker AMQP URL and credentials are never pushed over plaintext +(§3.9). + +The same service exposes a **narrowly-scoped update path** for the event-queue mode/TTL only: +`PATCH /api/management/v1/external-plugin/host/{hostId}/event-queue` with +`{eventQueueMode, eventQueueTtlMs}`. `baseUrl`, `secret`, `eventBrokerAmqpUrl`, and +`eventBrokerExchange` remain immutable — the security check that pins broker credentials to a +confidential `baseUrl` only needs to run at registration. After the PATCH, the resource triggers +`discoveryService.discoverAll()` so the host's `EventConsumerManager.sync()` swaps the queue +immediately instead of waiting for the next polling tick. + +## 7. Plugin host 🟡 (`plugin-host/app/`, Node + Fastify + Extism) + +Routes: `GET /health`; `*/api/host/plugins[...]` (HMAC-signed §3.9; POST upload, GET list — +each listing entry carries the package `contentHash` GZAC pins at discovery, §11 — and DELETE); +`POST|PUT|DELETE|GET /api/host/configurations/:configId` (HMAC-signed §3.9; push body +carries `pluginId, pluginVersion, properties, serviceToken, gzacBaseUrl, eventSubscriptions, +grantedCapabilities, grantedEndpoints` and optionally `eventBroker` and `expectedContentHash` — +only `serviceToken`/`gzacBaseUrl` are actually validated, `pluginId`/`pluginVersion` are not +null-checked beyond the plugin having to be loaded, and a push whose `expectedContentHash` does +not match the loaded package's hash is refused with 409 (§11) so a config and its fresh service +token can never reach package bytes other than the pinned ones); `POST +/plugins/:id/:version/actions/:key` +(HMAC-signed §3.9 — **no GET variant**); public `GET …/plugin-manifest`, `…/logo`, +`…/bundles/**` (bundles and logo are served with the strict plugin-content CSP — see below), and +`POST …/data` (the `handle_request` RPC route, §13.4/§13.5 — browser-facing +with CORS `*` + `OPTIONS` preflight, so it carries no HMAC, but executing Wasm is gated on a +chain of checks: the request must name a `configurationId` whose pushed configuration exists, +targets this plugin version, **and was granted the `frontend_data` capability** — otherwise 403 +with a single deliberately-uninformative message so the public endpoint doesn't leak which +configurations exist; a per-configuration fixed-window rate limit (`DATA_RATE_LIMIT_PER_MINUTE`, +default 120/min, in-memory per replica) bounds abuse; and the request **must carry a GZAC-minted +downscoped `userToken`** (400 when absent), which the host validates by remote introspection +against the configuration's GZAC (`GET /api/v1/external-plugin/user-token/introspect`, the token +itself as bearer credential, bounded by `USER_TOKEN_INTROSPECTION_TIMEOUT_MS`, default 10 s) — +GZAC rejecting the token → 401, a token bound to a **different** configuration than the request +names → 403, GZAC unreachable → 503 (**fail closed**: Wasm never runs on an unvalidated token); +positive verdicts are cached in-memory for ≤60 s (never past the token's own expiry), keyed by a +SHA-256 hash of the token, so steady-state calls cost no GZAC round-trip per call). Multi-version +load keyed `pluginId@version`. The +registered host functions are `gzac_api` (which can also authenticate as the user, §13.4), +`http_request`, `kv`, and `log` — all four gated by a per-configuration capability allowlist (§18). + +Configs are **persisted to PostgreSQL**; `ConfigRegistry` sits over `ConfigRepository` with a +**short-TTL in-memory read cache** (`CONFIG_CACHE_TTL_MS`, default 10 s) so hot paths — one lookup +per consumed event per configuration, one per data/action call — don't hit Postgres every time. +Writes through the registry (push/delete) invalidate the cache immediately; a write done by +another replica against the shared database becomes visible after at most the TTL; the +plugin-delete guard's `listByPlugin` reads uncached (staleness there would risk deleting a plugin +a just-pushed configuration references). The plugin manager serialises calls per plugin (a `lock` +promise chain to avoid Extism reentrancy — unload, delete, and idle eviction chain through the +**same** lock, so an instance is never closed mid-execution), sets `prefetch` on the broker +channel, and computes each loaded package's `contentHash` at load time (§11). Every Wasm call is +bounded by a hard wall-clock limit +(`WASM_TIMEOUT_MS`, default 30 s): Extism cancels a timed-out call, the cached instance is +dropped and the next call starts from a fresh one. The module's linear memory is capped +(`WASM_MAX_MEMORY_PAGES`, default 4096 pages = 256 MiB; 0 uncaps), and idle instances — each +holding a worker thread plus Wasm memory — are evicted by a periodic sweep after +`WASM_INSTANCE_IDLE_TTL_MS` (default 10 min) without a call; the next call transparently +re-instantiates. All four exports (`handle_action`/`handle_event`/`handle_request`/`handle_submit`) +funnel through one generic `callExport`; the public `callAction`/`callEvent`/`callRequest`/ +`callSubmit` wrappers only shape their input and host context. + +- **Action HTTP body** (GZAC→host): `{configurationId, processInstanceId, activityId, documentId?, + properties}` — note it does **not** carry `actionKey` (URL param) or `configuration` (looked up + host-side from the registry). Before the invocation, `ExternalPluginServiceTaskStartListener` + resolves the link's action properties against the process context: a textual value is routed + through `ValueResolverService` **only when a resolver factory actually supports its prefix** + (`ValueResolverService.supportsValue` — `pv:`, `doc:`, `case:`, …); a literal that merely + contains a colon (e.g. `https://example.com`) passes through untouched instead of tripping the + resolver on an unknown prefix. The host assembles the **Wasm input** `{actionKey, configurationId, + configuration, processInstanceId, documentId, activityId, properties}`; output `{status, + variables, result?}` (plus `{errorCode, errorMessage}` on failure, surfaced to the process as a + BPMN error). `variables` is applied as plain Operaton process variables; the optional `result` + is a separate channel evaluated only by the link's `action_result_mappings` (§21) — the two + never interfere, and a plugin that returns no `result` simply has nothing to map. On the GZAC + side, `ExternalPluginHostClient` maps **every** failure mode of an action/submit invocation onto + a structured `ActionResponse` so the callers' error paths always engage: a 4xx/5xx from the host + becomes the host's status plus its parsed error body, and a connection failure or timeout + becomes a synthetic 503 carrying `errorCode: EXTERNAL_PLUGIN_HOST_UNREACHABLE` — an unreachable + host surfaces to the process as a BPMN error like any other action failure. +- **Plugins run under Extism with `runInWorker: true`** so async host functions (`gzac_api`) can + suspend the Wasm call until the host's fetch resolves. **This requires Node ≥ 22** (older Node + fails to spawn the worker with `invalid execArgv flags: --disable-warning`). +- **`DELETE /api/host/plugins/:pluginId/:version`** refuses removal with HTTP 409 if any active + configurations on the host reference the plugin version + (`configRegistry.listByPlugin(pluginId, version)`), returning the offending `configurationIds`. +- **Upload safety** (`POST /api/host/plugins`): the package size is capped (`UPLOAD_MAX_BYTES`, + default 25 MB; a truncated multipart stream is rejected explicitly with 413 rather than slipping + through as a corrupt zip), and the zip is extracted **entry by entry** with zip-slip protection + (`safeExtractPluginZip`): every entry's resolved destination must stay inside the extraction + directory — a crafted `../`, absolute, or drive-letter entry name rejects the whole package with + 400 — and only the files a plugin package may legitimately carry are extracted (root-level + `manifest.json`, `plugin.wasm`, the logo, and `frontend/**`), so a hostile zip cannot plant + anything else even inside the temp dir. **A version is never replaced silently**: an upload + whose manifest names a `pluginId@version` that already exists — loaded in memory *or* present + on disk (`hasVersion`) — is refused with 409 carrying `code: PLUGIN_VERSION_EXISTS` and both + content hashes (the loaded package's and the uploaded package's, so callers can tell an + identical re-upload apart from different content). Only `?overwrite=true` — which GZAC sends + after an admin explicitly confirmed the overwrite and re-reviewed the requested permissions + (§11) — replaces the package (hot-reload; logged as a warn for audit). The 201 response + carries the stored package's `contentHash` (§11). +- **Plugin-content CSP** (`routes/plugin-bundles.ts`): every response serving plugin-authored + content — `…/bundles/**` **and** the logo (an SVG can carry script) — carries + `Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' + 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self'; + form-action 'self'; base-uri 'none'; object-src 'none'; sandbox allow-scripts allow-forms`, + plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. The iframe sandbox + (§13.2) stops *escalation*; this policy closes the *exfiltration* channels a hostile bundle + would otherwise have — `connect-src 'self'` kills fetch/XHR/beacon to third parties (opaque- + origin requests go out with `Origin: null`, which plenty of endpoints accept), `script-src + 'self'` kills remote script loading, `img-src`/`font-src` kill pixel-beacon exfil, and + `form-action 'self'` kills native form posts to external endpoints. An honest plugin loses + nothing: its GZAC traffic flows through the parent-proxy postMessage transport and its own + assets live under the bundle path. The CSP `sandbox` directive mirrors the embedding iframe's + attribute, so a bundle opened directly in a top-level tab is confined to an opaque origin too, + instead of running same-origin with the host. + +Environment (`models/app-config.ts`): `ADMIN_TOKEN` (required — the shared secret used as the +HMAC key for every GZAC→host route, §3.9), `PORT` (8090), +`PLUGIN_STORAGE_DIR` (`./plugins`), `LOG_LEVEL` (info), `HOST_ID` (defaults to the OS hostname; +see §8.4), the execution/abuse bounds `WASM_TIMEOUT_MS` (30 s), `WASM_MAX_MEMORY_PAGES` (4096), +`WASM_INSTANCE_IDLE_TTL_MS` (10 min), `GZAC_API_TIMEOUT_MS` (60 s), `UPLOAD_MAX_BYTES` (25 MB), +`DATA_RATE_LIMIT_PER_MINUTE` (120) and `CONFIG_CACHE_TTL_MS` (10 s), plus `DB_HOST` / `DB_PORT` +(defaults to **5434**, not the standard 5432) / `DB_NAME` / `DB_USER` / `DB_PASSWORD` for the +host's PostgreSQL, and optional `TLS_CERT_PATH` / `TLS_KEY_PATH` (set together to serve HTTPS — +§3.9) plus `TLS_CA_PATH` for a certificate chain. `HOST_ALLOW_HTTP` / `HOST_ALLOW_PRIVATE_NETWORK` +relax the `http_request` target policy for local development (§18.6), and `LOG_RETENTION_DAYS` +(30) drives the log-retention job (§18.9). **No broker variables** — the host never configures a +broker itself. + +Gaps to close for production: no HTMX `render_page`. +Host capabilities (`gzac_api`, `http_request`, `kv`, `log`, `frontend_data`) and their persistent +storage are covered in §18. + +## 8. Event subscription & delivery ✅ + +End-to-end, an event the core app emits is delivered to every subscribed plugin configuration's +`handle_event`, which may call back into the core app. + +``` +GZAC domain event + └─ OutboxService (same TX) → outbox_message + └─ PollingPublisherJob (~3s) → RabbitMessagePublisher.convertAndSend("valtimo-events", "", cloudEvent) + └─ exchange valtimo-events (fanout, durable) + ├─ valtimo-audit (core app's own consumer) + ├─ valtimo-inbox (core app's own consumer) + └─ valtimo-external-plugins.. ← each plugin host's own queue + └─ EventConsumerManager → handle_event(EventInput) → onEvent(...) + └─ optional gzac_api callback (service token + allowlist enforced) +``` + +### 8.1 Publish (core app) + +Domain events extend `com.ritense.outbox.domain.BaseEvent` and are serialized as CloudEvents v1.0 +JSON by `CloudEventFactory`. `RabbitMessagePublisher` sends them with +`convertAndSend(exchange, routingKey, body)` where `exchange = valtimo-events` (from +`valtimo.outbox.publisher.rabbitmq.exchange`) and `routingKey` is empty. `valtimo-events` is a +**fanout, durable** exchange declared in `backend/app/gzac/imports/gzac-rabbitmq/definitions.json` +(also bound to the core app's `valtimo-audit` and `valtimo-inbox` queues). + +### 8.2 Per-host broker and granted subscriptions, pushed by GZAC + +The plugin host is **not** configured with a broker URL via env variables. It learns each +configuration's broker from the GZAC push, so one host can serve many GZAC instances and many +hosts can serve one instance. + +`ExternalPluginConfigurationService.pushToHost(config, definition, host)` reads: +- The broker fields off the host row, with `host.eventBrokerExchange` falling back to the outbox + exchange when null and `exchangeType` hardcoded `fanout`. `eventBrokerAmqpUrl` being null + causes the entire `eventBroker` block to be omitted from the push body — actions still work, + events don't. +- The granted event types off `external_plugin_granted_event` for this configuration. These are + sent as the push body's `eventSubscriptions` array — the host's authoritative subscription set + for this configuration, matching the manifest's declared list (§3.1). +- The granted capabilities off `external_plugin_granted_capability`, sent as the push body's + `grantedCapabilities` array — the allowlist the host's `guardHostCall` checks on every host + function invocation (§18.4). + +`pushToHost` is deliberately **not** transactional — it performs HTTP I/O and must never run +inside a database transaction. Activation and update register the push as an after-commit action +(`runAfterCommit`), so a slow or unreachable host can never pin a database transaction open; a +failed push is a warning only, self-healed by the next discovery re-sync. Configuration deletes +remove the config from the host through the same after-commit mechanism. `pushToHost` refuses to +push at all — every caller funnels through it — while the definition's package content awaits +re-acceptance (§11): no push means no fresh service token for package bytes the admin has not +accepted. + +Push body shape (relevant fields): + +```json +{ + "pluginId": "case-summary", + "pluginVersion": "0.1.0", + "properties": { }, + "serviceToken": "eyJ…", + "gzacBaseUrl": "http://gzac:8080", + "expectedContentHash": "sha256:…", + "eventSubscriptions": ["com.ritense.valtimo.document.created", "com.ritense.valtimo.task.completed"], + "grantedCapabilities": ["gzac_api", "log"], + "grantedEndpoints": [{"method": "GET", "pattern": "/api/v1/document/*"}], + "eventBroker": { + "amqpUrl": "amqp://…", + "exchange": "valtimo-events", + "exchangeType": "fanout", + "queueMode": "live", + "queueTtlMs": null + } +} +``` + +`expectedContentHash` is the definition's pinned package hash (§11; omitted while none is pinned). +The host verifies its loaded package still matches before accepting the push and answers 409 +otherwise, closing the window between GZAC's discovery cycle and the push itself. + +Every push carries the configuration's granted endpoint list as a `grantedEndpoints` array +(`{method, pattern}` entries), persisted in the host's `granted_endpoints` column (`NULL` when a +push carries no array) and enforced host-side by `gzac_api` (§3.5): the array is filtered to +well-formed entries and an **empty result denies all**, while a configuration whose push carried +no list at all is allowed with a warning (compatibility for pushes from other clients) — GZAC's +server-side allowlist filter (§3.4) remains the authoritative gate either way. + +`queueMode` is `"live"` or `"durable"` (lowercased on the wire — the host's `normalizeEventBroker` +defaults unknown/absent values to `"live"`, so older GZACs that don't push it stay compatible). +`queueTtlMs` is present only when `queueMode === "durable"` and is clamped defensively to the +1h–30d window even though GZAC validates the same bounds at registration / PATCH time. + +### 8.3 Consume (host, `rabbitmq/event-consumer.ts`) + +`EventConsumerManager` keeps one `BrokerConsumer` per **distinct broker** +(`brokerKey = amqpUrl + exchange + exchangeType`). Note: `queueMode`/`queueTtlMs` are intentionally +**not** in the broker key — they are queue-level concerns, not connection-level, so two +configurations on the same broker still share a single connection while the queue arguments come +from the host-wide mode. After any configuration mutation the route calls `sync()` (serialised via +a promise chain): it opens consumers for newly referenced brokers and closes consumers no +configuration references any more. A `BrokerConsumer`: +- `assertExchange(exchange, exchangeType, { durable: true })`, +- `assertQueue("valtimo-external-plugins...", …)` with arguments + switched per mode: + - **`live`** (default): `{ durable: false, autoDelete: true }` — queue evaporates when the host + disconnects; events while the host is fully down are lost (live-subscription semantics). + - **`durable`**: `{ durable: true, autoDelete: false, arguments: { "x-expires": queueTtlMs } }` — + queue survives host restarts; `x-expires` deletes the queue after `queueTtlMs` of no-consumer + inactivity, so a host that vanishes permanently doesn't accumulate events forever. + + The mode suffix in the queue name means flipping `queueMode` produces a different queue and so + never collides with the previous queue's `assertQueue` arguments — the old `.live` queue + auto-deletes on disconnect; an orphan `.durable` queue lingers until its `x-expires` fires or an + operator deletes it from the management UI. +- `bindQueue(queue, exchange, "")` (fanout ignores the routing key), +- `consume(..., { noAck: false })` — ack on success; a malformed message is `nack`-dropped (not + requeued) to avoid a poison loop. There is **no DLQ** today; expired or dropped messages are + silently lost. + +Restart behaviour: configs are persisted in the host's PostgreSQL (`plugin_configurations` table). +On boot the host calls `eventConsumerManager.sync()` which re-opens consumers for every config +that still carries an `eventBroker.amqpUrl`. Expect a `"Broker consumer started"` log line at +startup if any persisted configs reference a broker, even before GZAC sends a fresh push. + +**Self-healing reconnect.** Once `BrokerConsumer.start()` has succeeded the consumer owns its own +reconnect loop: an unexpected `close` on the AMQP connection schedules a backed-off reconnect +(`1s, 2s, 4s, …`, capped at 30s with 50–100 % jitter) and the consumer stays in the manager's map +across the gap, so delivery resumes without a configuration push or host restart. A successful +reconnect resets the backoff and re-asserts the exchange, queue, binding, and `consume`. The loop +terminates only on intentional close — when the manager's `sync()` removes a broker that is no +longer referenced by any configuration, or when the host shuts down. The initial `start()` call +keeps its strict contract: if connecting to a broker that has *never* worked fails, the consumer is +left out of the map and the next `sync()` retries — only post-success drops are self-healed. The +auto-delete live-subscription queue (§8.4) is re-created on every reconnect, so events published +during a disconnected window are still not retained for the host. + +### 8.4 Dispatch & multi-host topologies + +For each consumed CloudEvent the manager iterates the config registry and invokes `handle_event` +for every configuration that (a) carries the **same broker key** as the consuming connection (so +instance A's events never reach instance B's configs) **and** (b) whose stored +`eventSubscriptions` (the granted set pushed by GZAC, persisted in the host's +`plugin_configurations.event_subscriptions` column) contains the CloudEvent `type`. + +The manifest's declared `eventSubscriptions` is **not consulted at dispatch time** — only the +granted set is. This is the security gate that prevents a plugin author from silently expanding +the dispatched event set: publishing a new plugin version that adds an event type to the manifest +does not start delivering that type until an admin explicitly re-grants. The same configuration's +running v2 keeps receiving only what was originally accepted. + +The Wasm `EventInput` is the flattened event (`type, id, source, time, userId, roles, resultType, +resultId, result`) plus the configuration's `properties`. `serviceToken` and `gzacBaseUrl` ride in +the Extism per-call `hostContext`, so an event handler's `gzac_api` callback is authenticated and +allowlist-enforced exactly like an action's. + +Multi-host topologies: +- *Different* hosts on one GZAC instance have distinct queues → **every host receives a copy** of + each event. +- *Replicas of the same host* (shared `HOST_ID`) bind the **same** queue and become competing + consumers → each event is handled by **exactly one** replica. +- *One host serving multiple GZAC instances*: each instance has its own broker, so the host opens + a separate `BrokerConsumer` per broker. Dispatch only fires configurations whose pushed broker + key matches the consuming connection. +- Durability trade-off (configurable per host): `live` mode preserves today's no-overhead + semantics — events published while the host is fully down are not retained. `durable` mode + retains buffered events up to `queueTtlMs` since the last consumer disconnected, at the cost of + a queue that has to be cleaned up if a host is deprovisioned and its `HOST_ID` never returns + (the TTL is the automatic cleanup). Plugin handlers must already be idempotent because gzac's + outbox is at-least-once, so durable replay does not change handler-correctness requirements. + +### 8.5 SDK & declaration + +A plugin declares the CloudEvent types it cares about in `manifest.json`: + +```json +"eventSubscriptions": ["com.ritense.valtimo.document.created", "com.ritense.valtimo.task.completed"] +``` + +and registers a handler with `onEvent` (`plugin-sdk/src/events.ts`); the SDK runtime +(`plugin-sdk/src/runtime.ts`) exports `handle_event`, settling async handlers synchronously under +QuickJS and reporting `{status: "completed" | "ignored" | "error"}`. Multiple handlers may +register; all run per event. + +## 9. SDK & developer experience ✅ + +A plugin author writes `src/plugin.ts`: import `{action, onEvent, request, config, gzacApi, log}` +from `@valtimo/plugin-sdk`; `action("key", (input) => ({status, variables}))`; +`onEvent((event) => …)`; `request("/path", (input) => ({status, body}))` for iframe-served JSON +data (§13.4); read config via synchronous `config.get()`; call `gzacApi.{get,post,put,delete}()` +as the **service token**, or `gzacApi.asUser.{…}()` as the **downscoped user token** (§13.4) — both +synchronous from the plugin's view (the host suspends the call). Build: `valtimo-plugin-build` +(esbuild → `extism-js`) then `valtimo-plugin-pack` (zip of `manifest.json` + `plugin.wasm` + +`frontend/` + optional `logo.{svg,png,jpg,jpeg}`). + +DX done: the build auto-generates the Wasm interface (`handle_action` + `handle_event` + +`handle_request` exports + `gzac_api` import) so authors write only `src/plugin.ts`; the runtime +settles returned promises and never serialises a pending `Promise`; the pack copies `manifest.json` +verbatim so `eventSubscriptions`, `permissions`, and `translations` carry through — additionally +stamping the SDK package's own version onto the in-zip manifest as `sdkVersion`, so the host can +tell which SDK/ABI a stored plugin targets (the upload validator requires it to be a non-empty +string when present) — and compiles each +`frontend/*.tsx` referenced by a `frontend/*.html` ` + + diff --git a/plugin-host/sample-apps/demo-app/frontend/action-config.tsx b/plugin-host/sample-apps/demo-app/frontend/action-config.tsx new file mode 100644 index 0000000000..5e78073433 --- /dev/null +++ b/plugin-host/sample-apps/demo-app/frontend/action-config.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ValtimoPluginSDK } from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 16px", + fontSize: "14px", + border: "1px solid #8d8d8d", + backgroundColor: "#f4f4f4", + outline: "none", + boxSizing: "border-box", +}; +const labelStyle: React.CSSProperties = { display: "block", marginBottom: "4px", fontSize: "12px", color: "#525252" }; +const helpTextStyle: React.CSSProperties = { fontSize: "12px", color: "#6f6f6f", marginTop: "4px" }; + +function ActionConfigForm() { + const [name, setName] = useState(""); + const [greetingVariable, setGreetingVariable] = useState("greeting"); + + useEffect(() => { + sdk.onPrefillConfiguration(({ configuration }) => { + if (typeof configuration.name === "string") setName(configuration.name); + if (typeof configuration.greetingVariable === "string") setGreetingVariable(configuration.greetingVariable); + }); + sdk.onSave(() => { + /* no-op */ + }); + sdk.emit("ready", {}); + }, []); + + // Action config carries no title (the empty string) — only the per-activity properties. + const emit = useCallback((newName: string, newVar: string) => { + sdk.setConfiguration(true, "", { + name: newName.trim() || undefined, + greetingVariable: newVar.trim() || "greeting", + }); + }, []); + + return ( +
+
+ + { + setName(e.target.value); + emit(e.target.value, greetingVariable); + }} + /> +

{sdk.t("action.name.help")}

+
+
+ + { + setGreetingVariable(e.target.value); + emit(name, e.target.value); + }} + /> +

{sdk.t("action.greetingVariable.help")}

+
+
+ ); +} + +sdk.ready().then(() => { + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-apps/demo-app/frontend/case-tab.html b/plugin-host/sample-apps/demo-app/frontend/case-tab.html new file mode 100644 index 0000000000..e22459fdbb --- /dev/null +++ b/plugin-host/sample-apps/demo-app/frontend/case-tab.html @@ -0,0 +1,16 @@ + + + + + + Demo App — Tab + + + +
+ + + diff --git a/plugin-host/sample-apps/demo-app/frontend/case-tab.tsx b/plugin-host/sample-apps/demo-app/frontend/case-tab.tsx new file mode 100644 index 0000000000..372ead9a5f --- /dev/null +++ b/plugin-host/sample-apps/demo-app/frontend/case-tab.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ValtimoPluginSDK } from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const panelStyle: React.CSSProperties = { border: "1px solid #e0e0e0", padding: "16px", marginBottom: "16px", background: "#fff" }; +const panelTitleStyle: React.CSSProperties = { fontSize: "14px", fontWeight: 600, color: "#161616", marginBottom: "8px" }; +const rowStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", padding: "4px 0", fontSize: "14px", color: "#393939", borderBottom: "1px solid #f4f4f4" }; +const mutedStyle: React.CSSProperties = { color: "#6f6f6f", fontSize: "14px" }; +const errorStyle: React.CSSProperties = { color: "#da1e28", fontSize: "14px" }; + +interface InfoData { + message: string; + greetingPrefix: string; + now: string; +} +interface ScopeResult { + tokenType: "user" | "plugin"; + upstreamStatus: number; + totalElements: number | null; +} +type LoadState = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; data: T }; + +function ScopePanel(props: { titleKey: string; descKey: string; state: LoadState }) { + const { titleKey, descKey, state } = props; + return ( +
+
{sdk.t(titleKey)}
+
{sdk.t(descKey)}
+ {state.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {state.state === "error" &&
{state.message}
} + {state.state === "ready" && ( +
+
+ {sdk.t("caseTab.backend.upstreamStatus")} + {state.data.upstreamStatus} +
+ {state.data.totalElements !== null ? ( +
+ {sdk.t("caseTab.backend.casesVisible")} + {state.data.totalElements} +
+ ) : ( +
{sdk.t("caseTab.backend.denied")}
+ )} +
+ )} +
+ ); +} + +function CaseTab() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + const caseDefinitionKey = (ctx.caseDefinitionKey as string | undefined) ?? null; + + const [info, setInfo] = useState>({ state: "loading" }); + const [valtimo, setValtimo] = useState>>({ state: "loading" }); + const [asUser, setAsUser] = useState>({ state: "loading" }); + const [asPlugin, setAsPlugin] = useState>({ state: "loading" }); + + // (2) App-served data — the app's own handle_request handler, no GZAC. + useEffect(() => { + sdk + .getPluginData("/info") + .then((res) => + res.status >= 200 && res.status < 300 + ? setInfo({ state: "ready", data: res.body as InfoData }) + : setInfo({ state: "error", message: sdk.t("caseTab.plugin.error") }), + ) + .catch((err) => setInfo({ state: "error", message: String(err?.message ?? err) })); + }, []); + + // (3) Valtimo data, scoped to the logged-in user (user token via the parent-proxy). + useEffect(() => { + if (!documentId) { + setValtimo({ state: "error", message: sdk.t("caseTab.valtimo.noDocument") }); + return; + } + sdk + .callValtimo("GET", `/api/v1/document/${documentId}`) + .then((res) => { + if (res.status >= 200 && res.status < 300) setValtimo({ state: "ready", data: res.body as Record }); + else if (res.status === 403) setValtimo({ state: "error", message: sdk.t("caseTab.valtimo.forbidden") }); + else setValtimo({ state: "error", message: sdk.t("caseTab.valtimo.error") }); + }) + .catch((err) => setValtimo({ state: "error", message: String(err?.message ?? err) })); + }, [documentId]); + + // (4 & 5) App backend → GZAC as the user vs as the app (compare token scopes). + useEffect(() => { + if (!caseDefinitionKey) { + const noCtx: LoadState = { state: "error", message: sdk.t("caseTab.backend.noContext") }; + setAsUser(noCtx); + setAsPlugin(noCtx); + return; + } + loadScope("/case-count-as-user", setAsUser); + loadScope("/case-count-as-plugin", setAsPlugin); + }, [caseDefinitionKey]); + + useEffect(() => { + sdk.emit("resize", { height: document.documentElement.scrollHeight }); + }, [info, valtimo, asUser, asPlugin]); + + return ( +
+ {/* (1) Hello world — static translated text. */} +
+
{sdk.t("caseTab.hello.title")}
+
{sdk.t("caseTab.hello")}
+
+ + {/* (2) App-served data. */} +
+
{sdk.t("caseTab.plugin.title")}
+ {info.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {info.state === "error" &&
{info.message}
} + {info.state === "ready" && ( +
+
{info.data.message}
+
+ greetingPrefix + {info.data.greetingPrefix} +
+
+ )} +
+ + {/* (3) Valtimo data, user-scoped. */} +
+
{sdk.t("caseTab.valtimo.title")}
+ {valtimo.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {valtimo.state === "error" &&
{valtimo.message}
} + {valtimo.state === "ready" && ( +
+ {sdk.t("caseTab.valtimo.definition")} + {describeDefinition(valtimo.data)} +
+ )} +
+ + {/* (4) app backend → GZAC (user token). */} + + {/* (5) app backend → GZAC (app/service token, broader scope). */} + +
+ ); +} + +function loadScope(path: string, setState: (s: LoadState) => void): void { + sdk + .getPluginData(path) + .then((res) => + res.status >= 200 && res.status < 300 + ? setState({ state: "ready", data: res.body as ScopeResult }) + : setState({ state: "error", message: sdk.t("caseTab.backend.error") }), + ) + .catch((err) => setState({ state: "error", message: String(err?.message ?? err) })); +} + +function describeDefinition(document: Record): string { + const definitionId = document.definitionId as { name?: string } | undefined; + return definitionId?.name ?? "(unknown)"; +} + +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-apps/demo-app/frontend/config.html b/plugin-host/sample-apps/demo-app/frontend/config.html new file mode 100644 index 0000000000..13ebfbaad2 --- /dev/null +++ b/plugin-host/sample-apps/demo-app/frontend/config.html @@ -0,0 +1,16 @@ + + + + + + Demo App — Configuration + + + +
+ + + diff --git a/plugin-host/sample-apps/demo-app/frontend/config.tsx b/plugin-host/sample-apps/demo-app/frontend/config.tsx new file mode 100644 index 0000000000..048f5664ca --- /dev/null +++ b/plugin-host/sample-apps/demo-app/frontend/config.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ValtimoPluginSDK } from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 16px", + fontSize: "14px", + border: "1px solid #8d8d8d", + backgroundColor: "#f4f4f4", + outline: "none", + boxSizing: "border-box", +}; +const labelStyle: React.CSSProperties = { display: "block", marginBottom: "4px", fontSize: "12px", color: "#525252" }; +const helpTextStyle: React.CSSProperties = { fontSize: "12px", color: "#6f6f6f", marginTop: "4px" }; + +function ConfigForm() { + const [title, setTitle] = useState(""); + const [greetingPrefix, setGreetingPrefix] = useState("Hello"); + + useEffect(() => { + sdk.onPrefillConfiguration(({ title: prefillTitle, configuration }) => { + if (prefillTitle) setTitle(prefillTitle); + if (configuration.greetingPrefix) setGreetingPrefix(configuration.greetingPrefix as string); + }); + sdk.onSave(() => { + /* parent already has the latest via configurationChanged */ + }); + sdk.emit("ready", {}); + }, []); + + const emit = useCallback((newTitle: string, newPrefix: string) => { + sdk.setConfiguration(newTitle.trim().length > 0, newTitle.trim(), { + greetingPrefix: newPrefix.trim() || "Hello", + }); + }, []); + + return ( +
+
+ + { + setTitle(e.target.value); + emit(e.target.value, greetingPrefix); + }} + /> +
+
+ + { + setGreetingPrefix(e.target.value); + emit(title, e.target.value); + }} + /> +

{sdk.t("config.greetingPrefix.help")}

+
+
+ ); +} + +sdk.ready().then(() => { + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-apps/demo-app/logo.svg b/plugin-host/sample-apps/demo-app/logo.svg new file mode 100644 index 0000000000..2518dce197 --- /dev/null +++ b/plugin-host/sample-apps/demo-app/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugin-host/sample-apps/demo-app/package-lock.json b/plugin-host/sample-apps/demo-app/package-lock.json new file mode 100644 index 0000000000..e2d4cd07c9 --- /dev/null +++ b/plugin-host/sample-apps/demo-app/package-lock.json @@ -0,0 +1,1844 @@ +{ + "name": "valtimo-demo-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "valtimo-demo-app", + "version": "1.0.0", + "license": "EUPL-1.2", + "dependencies": { + "amqplib": "^0.10.9", + "fastify": "^5.8.5", + "fastify-raw-body": "^5.0.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/amqplib": "^0.10.8", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@valtimo/plugin-sdk": "file:../../plugin-sdk", + "esbuild": "^0.25.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../../plugin-sdk": { + "name": "@valtimo/plugin-sdk", + "version": "0.1.0", + "dev": true, + "license": "EUPL-1.2", + "dependencies": { + "@extism/js-pdk": "^1.1.0", + "adm-zip": "^0.5.17" + }, + "bin": { + "valtimo-plugin-build": "bin/valtimo-plugin-build.mjs", + "valtimo-plugin-pack": "bin/valtimo-plugin-pack.mjs" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.4.0" + }, + "peerDependencies": { + "@extism/js-pdk": "^1.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "node_modules/@types/amqplib": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.8.tgz", + "integrity": "sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@valtimo/plugin-sdk": { + "resolved": "../../plugin-sdk", + "link": true + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/amqplib": { + "version": "0.10.9", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", + "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", + "dependencies": { + "buffer-more-ints": "~1.0.0", + "url-parse": "~1.5.10" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/buffer-more-ints": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", + "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.0.tgz", + "integrity": "sha512-YV53BAbR3Qwq37wfD1oZ97YJ0nYj6CwfzKXQ38ock9XxI2EnLOdl5psKms6Evook6ACckytZJOaKY0ThmIi1uw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify-raw-body": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fastify-raw-body/-/fastify-raw-body-5.0.0.tgz", + "integrity": "sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==", + "dependencies": { + "fastify-plugin": "^5.0.0", + "raw-body": "^3.0.0", + "secure-json-parse": "^2.4.0" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "url": "https://github.com/Eomm/fastify-raw-body?sponsor=1" + } + }, + "node_modules/fastify-raw-body/node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugin-host/sample-apps/demo-app/package.json b/plugin-host/sample-apps/demo-app/package.json new file mode 100644 index 0000000000..e0b1c53c4d --- /dev/null +++ b/plugin-host/sample-apps/demo-app/package.json @@ -0,0 +1,37 @@ +{ + "name": "valtimo-demo-app", + "version": "1.0.0", + "description": "Reference Valtimo App — a remote service that IS a plugin-host-plus-single-plugin, added to GZAC by URL", + "type": "module", + "main": "dist/index.js", + "scripts": { + "installDeps": "npm install", + "build:frontend": "node scripts/build-frontend.mjs", + "build": "npm run build:frontend && tsc", + "dev": "npm run build:frontend && ADMIN_TOKEN=test-secret tsx watch src/index.ts", + "start": "node dist/index.js", + "clean": "rm -rf dist public" + }, + "dependencies": { + "amqplib": "^0.10.9", + "fastify": "^5.8.5", + "fastify-raw-body": "^5.0.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/amqplib": "^0.10.8", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@valtimo/plugin-sdk": "file:../../plugin-sdk", + "esbuild": "^0.25.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "license": "EUPL-1.2" +} diff --git a/plugin-host/sample-apps/demo-app/scripts/build-frontend.mjs b/plugin-host/sample-apps/demo-app/scripts/build-frontend.mjs new file mode 100644 index 0000000000..d0a7a3de4a --- /dev/null +++ b/plugin-host/sample-apps/demo-app/scripts/build-frontend.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Builds the iframe bundles the app serves at `/plugins/{pluginId}/{version}/bundles/*`. + * + * For every `frontend/*.html` that references a ` + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/action-config.tsx b/plugin-host/sample-plugins/case-summary/frontend/action-config.tsx new file mode 100644 index 0000000000..0db4036fa7 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/action-config.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { ValtimoPluginSDK } from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 16px", + fontSize: "14px", + border: "1px solid #8d8d8d", + borderRadius: "0", + backgroundColor: "#f4f4f4", + outline: "none", + boxSizing: "border-box", +}; + +const labelStyle: React.CSSProperties = { + display: "block", + marginBottom: "4px", + fontSize: "12px", + color: "#525252", +}; + +const helpTextStyle: React.CSSProperties = { + fontSize: "12px", + color: "#6f6f6f", + marginTop: "4px", +}; + +interface ActionConfig { + titleField: string; + amountField: string; + summaryVariable: string; + definitionKeyVariable: string; +} + +function ActionConfigForm() { + const [titleField, setTitleField] = useState("/applicantName"); + const [amountField, setAmountField] = useState(""); + const [summaryVariable, setSummaryVariable] = useState("caseSummary"); + const [definitionKeyVariable, setDefinitionKeyVariable] = useState("caseDefinitionKey"); + + useEffect(() => { + sdk.onPrefillConfiguration(({ configuration }) => { + const config = configuration as unknown as ActionConfig; + if (config.titleField) setTitleField(config.titleField); + if (config.amountField) setAmountField(config.amountField); + if (config.summaryVariable) setSummaryVariable(config.summaryVariable); + if (config.definitionKeyVariable) setDefinitionKeyVariable(config.definitionKeyVariable); + }); + + sdk.onSave(() => { + // No-op + }); + + sdk.emit("ready", {}); + }, []); + + const emitConfig = useCallback( + (newTitleField: string, newAmountField: string, newSummaryVar: string, newDefKeyVar: string) => { + const valid = newTitleField.trim().length > 0; + sdk.setConfiguration(valid, "", { + titleField: newTitleField.trim(), + amountField: newAmountField.trim() || undefined, + summaryVariable: newSummaryVar.trim() || "caseSummary", + definitionKeyVariable: newDefKeyVar.trim() || "caseDefinitionKey", + } as unknown as Record); + }, + [] + ); + + const handleChange = ( + setter: React.Dispatch>, + field: "titleField" | "amountField" | "summaryVariable" | "definitionKeyVariable" + ) => (e: React.ChangeEvent) => { + const val = e.target.value; + setter(val); + const updated = { titleField, amountField, summaryVariable, definitionKeyVariable, [field]: val }; + emitConfig(updated.titleField, updated.amountField, updated.summaryVariable, updated.definitionKeyVariable); + }; + + return ( +
+
+ + +

{sdk.t("action.titleField.help")}

+
+ +
+ + +

{sdk.t("action.amountField.help")}

+
+ +
+ + +

{sdk.t("action.summaryVariable.help")}

+
+ +
+ + +

{sdk.t("action.definitionKeyVariable.help")}

+
+
+ ); +} + +sdk.ready().then(() => { + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.html b/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.html new file mode 100644 index 0000000000..14bde80a7d --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Details Tab + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.tsx new file mode 100644 index 0000000000..41765dfe9b --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-tab-details.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const panelStyle: React.CSSProperties = { + border: "1px solid #e0e0e0", + borderRadius: "0", + padding: "16px", + marginBottom: "16px", + background: "#ffffff", +}; + +const panelTitleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + gap: "16px", + padding: "4px 0", + fontSize: "14px", + color: "#393939", + borderBottom: "1px solid #f4f4f4", +}; + +const keyStyle: React.CSSProperties = { color: "#6f6f6f" }; +const mutedStyle: React.CSSProperties = { color: "#6f6f6f", fontSize: "14px" }; +const errorStyle: React.CSSProperties = { color: "#da1e28", fontSize: "14px" }; + +interface DocumentResponse { + id?: string; + createdBy?: string; + createdOn?: string; + content?: Record; + definitionId?: { name?: string; blueprintId?: { blueprintVersionTag?: string } }; +} + +type LoadState = + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; data: T }; + +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + sdk.emit("resize", { height: document.documentElement.scrollHeight }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function flatten(content: Record, prefix = ""): Array<{ key: string; value: string }> { + const rows: Array<{ key: string; value: string }> = []; + for (const [key, value] of Object.entries(content)) { + const label = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + rows.push(...flatten(value as Record, label)); + } else { + rows.push({ key: label, value: Array.isArray(value) ? JSON.stringify(value) : String(value) }); + } + } + return rows; +} + +function CaseDetailsTab() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [document_, setDocument] = useState>({ state: "loading" }); + + useEffect(() => { + if (!documentId) { + setDocument({ state: "error", message: sdk.t("caseTab.valtimo.noDocument") }); + return; + } + sdk + .callValtimo("GET", `/api/v1/document/${documentId}`) + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setDocument({ state: "ready", data: res.body as DocumentResponse }); + } else if (res.status === 403) { + setDocument({ state: "error", message: sdk.t("caseTab.valtimo.forbidden") }); + } else { + setDocument({ state: "error", message: sdk.t("caseTab.valtimo.error") }); + } + }) + .catch((err) => setDocument({ state: "error", message: String(err?.message ?? err) })); + }, [documentId]); + + useResizeEmitter([document_]); + + const contentRows = + document_.state === "ready" ? flatten(document_.data.content ?? {}) : []; + + return ( +
+
+
{sdk.t("caseTabDetails.hello.title")}
+
{sdk.t("caseTabDetails.hello")}
+
+ +
+
{sdk.t("caseTabDetails.content.title")}
+ {document_.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {document_.state === "error" &&
{document_.message}
} + {document_.state === "ready" && contentRows.length === 0 && ( +
{sdk.t("caseTabDetails.content.empty")}
+ )} + {document_.state === "ready" && + contentRows.map((row) => ( +
+ {row.key} + {row.value} +
+ ))} +
+
+ ); +} + +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-tab.html b/plugin-host/sample-plugins/case-summary/frontend/case-tab.html new file mode 100644 index 0000000000..f6c6c2d6e1 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-tab.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Tab + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-tab.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-tab.tsx new file mode 100644 index 0000000000..f658b2a59d --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-tab.tsx @@ -0,0 +1,313 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const panelStyle: React.CSSProperties = { + border: "1px solid #e0e0e0", + borderRadius: "0", + padding: "16px", + marginBottom: "16px", + background: "#ffffff", +}; + +const panelTitleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + padding: "4px 0", + fontSize: "14px", + color: "#393939", + borderBottom: "1px solid #f4f4f4", +}; + +const mutedStyle: React.CSSProperties = { color: "#6f6f6f", fontSize: "14px" }; +const errorStyle: React.CSSProperties = { color: "#da1e28", fontSize: "14px" }; + +interface SummaryData { + message: string; + currency: string; + documentId: string | null; + viewCount: number; + items: Array<{ label: string; value: string }>; +} + +interface ExternalData { + todo: { id: number; title: string; completed: boolean } | null; + viewCount: number; + fetchStatus: number; +} + +interface BackendScopeResult { + tokenType: "user" | "plugin"; + upstreamStatus: number; + caseDefinitionKey: string; + totalElements: number | null; +} + +type LoadState = + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; data: T }; + +/** Renders the result of a "tab → plugin backend → GZAC" case-count call (levels 3 & 4). */ +function BackendScopePanel(props: { + titleKey: string; + descKey: string; + state: LoadState; +}) { + const { titleKey, descKey, state } = props; + return ( +
+
{sdk.t(titleKey)}
+
{sdk.t(descKey)}
+ {state.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {state.state === "error" &&
{state.message}
} + {state.state === "ready" && ( +
+
+ {sdk.t("caseTab.backend.upstreamStatus")} + {state.data.upstreamStatus} +
+ {state.data.totalElements !== null ? ( +
+ {sdk.t("caseTab.backend.casesVisible")} + {state.data.totalElements} +
+ ) : ( +
{sdk.t("caseTab.backend.denied")}
+ )} +
+ )} +
+ ); +} + +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + // The Angular parent auto-resizes the iframe from this message. + const height = document.documentElement.scrollHeight; + sdk.emit("resize", { height }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function CaseTab() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [pluginData, setPluginData] = useState>({ state: "loading" }); + const [valtimoData, setValtimoData] = useState>>({ + state: "loading", + }); + const [scopeAsUser, setScopeAsUser] = useState>({ state: "loading" }); + const [scopeAsPlugin, setScopeAsPlugin] = useState>({ + state: "loading", + }); + const [externalData, setExternalData] = useState>({ state: "loading" }); + + // External API data — fetched via http_request + kv capabilities. + useEffect(() => { + sdk + .getPluginData("/external-data") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setExternalData({ state: "ready", data: res.body as ExternalData }); + } else { + setExternalData({ state: "error", message: sdk.t("caseTab.external.error") }); + } + }) + .catch((err) => setExternalData({ state: "error", message: String(err?.message ?? err) })); + }, []); + + // (4) tab -> plugin backend -> GZAC, with the downscoped user token (PBAC ∩ allowlist). + useEffect(() => { + loadScope("/case-count-as-user", setScopeAsUser); + }, []); + + // (5) tab -> plugin backend -> GZAC, with the service/plugin token (allowlist only; broader scope). + useEffect(() => { + loadScope("/case-count-as-plugin", setScopeAsPlugin); + }, []); + + // (2) Plugin-served data — fetched from the plugin's own handle_request handler. + useEffect(() => { + sdk + .getPluginData("/summary") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setPluginData({ state: "ready", data: res.body as SummaryData }); + } else { + setPluginData({ state: "error", message: sdk.t("caseTab.plugin.error") }); + } + }) + .catch((err) => setPluginData({ state: "error", message: String(err?.message ?? err) })); + }, []); + + // (3) Valtimo data, user-scoped — fetched from GZAC through the downscoped user token. + useEffect(() => { + if (!documentId) { + setValtimoData({ state: "error", message: sdk.t("caseTab.valtimo.noDocument") }); + return; + } + sdk + .callValtimo("GET", `/api/v1/document/${documentId}`) + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setValtimoData({ state: "ready", data: res.body as Record }); + } else if (res.status === 403) { + setValtimoData({ state: "error", message: sdk.t("caseTab.valtimo.forbidden") }); + } else { + setValtimoData({ state: "error", message: sdk.t("caseTab.valtimo.error") }); + } + }) + .catch((err) => setValtimoData({ state: "error", message: String(err?.message ?? err) })); + }, [documentId]); + + useResizeEmitter([pluginData, valtimoData, scopeAsUser, scopeAsPlugin, externalData]); + + return ( +
+ {/* (1) Hello world — static text via the translation table. */} +
+
{sdk.t("caseTab.hello.title")}
+
{sdk.t("caseTab.hello")}
+
+ + {/* (2) Plugin-served data + KV view counter. */} +
+
+
{sdk.t("caseTab.plugin.title")}
+ {pluginData.state === "ready" && ( + + {sdk.t("caseTab.viewCount")}: {pluginData.data.viewCount} + + )} +
+ {pluginData.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {pluginData.state === "error" &&
{pluginData.message}
} + {pluginData.state === "ready" && ( +
+
{pluginData.data.message}
+ {pluginData.data.items.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ )} +
+ + {/* External API data — http_request + kv capabilities. */} +
+
{sdk.t("caseTab.external.title")}
+ {externalData.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {externalData.state === "error" &&
{externalData.message}
} + {externalData.state === "ready" && externalData.data.todo && ( +
+
+ {sdk.t("caseTab.external.todoTitle")} + {externalData.data.todo.title} +
+
+ {sdk.t("caseTab.external.todoCompleted")} + {externalData.data.todo.completed ? "✓" : "✗"} +
+
+ {sdk.t("caseTab.viewCount")} + {externalData.data.viewCount} +
+
+ )} + {externalData.state === "ready" && !externalData.data.todo && ( +
{sdk.t("caseTab.external.error")}
+ )} +
+ + {/* (3) Valtimo data, scoped to the logged-in user (PBAC ∩ allowlist). */} +
+
{sdk.t("caseTab.valtimo.title")}
+ {valtimoData.state === "loading" &&
{sdk.t("caseTab.loading")}
} + {valtimoData.state === "error" &&
{valtimoData.message}
} + {valtimoData.state === "ready" && ( +
+ {sdk.t("caseTab.valtimo.definition")} + {describeDefinition(valtimoData.data)} +
+ )} +
+ + {/* (4) tab -> plugin backend -> GZAC with the user token (PBAC ∩ allowlist). */} + + + {/* (5) tab -> plugin backend -> GZAC with the service/plugin token (broader than the user). */} + +
+ ); +} + +function loadScope( + path: string, + setState: (state: LoadState) => void +): void { + sdk + .getPluginData(path) + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setState({ state: "ready", data: res.body as BackendScopeResult }); + } else { + setState({ state: "error", message: sdk.t("caseTab.backend.error") }); + } + }) + .catch((err) => setState({ state: "error", message: String(err?.message ?? err) })); +} + +function describeDefinition(document: Record): string { + const definitionId = document.definitionId as + | { name?: string; blueprintId?: { blueprintVersionTag?: string } } + | undefined; + if (!definitionId) return "(unknown)"; + const name = definitionId.name ?? "unknown"; + const version = definitionId.blueprintId?.blueprintVersionTag; + return version ? `${name} v${version}` : name; +} + +// Wait for the SDK to fetch the manifest + receive init (context) before mounting, so `sdk.t(key)` +// and `sdk.getContext()` are populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html new file mode 100644 index 0000000000..c5ade00754 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Metrics Widget + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx new file mode 100644 index 0000000000..a975050d92 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx @@ -0,0 +1,129 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const cardStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "16px", + fontSize: "14px", + color: "#393939", +}; + +const titleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; +const mutedStyle: React.CSSProperties = {color: "#6f6f6f", marginBottom: "12px"}; +const errorStyle: React.CSSProperties = {color: "#da1e28"}; + +const tilesStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "8px", +}; +const tileStyle: React.CSSProperties = { + background: "#f4f4f4", + padding: "12px", + borderRadius: "0", +}; +const tileLabelStyle: React.CSSProperties = {fontSize: "12px", color: "#6f6f6f"}; +const tileValueStyle: React.CSSProperties = {fontSize: "20px", fontWeight: 600, color: "#161616"}; + +interface SummaryData { + currency: string; + viewCount: number; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +/** The Angular parent has no host-side resize handling for widgets; still emit so future surfaces can use it. */ +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + sdk.emit("resize", {height: document.documentElement.scrollHeight}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function shortId(documentId: string | null): string { + if (!documentId) return "—"; + return documentId.length > 8 ? `${documentId.slice(0, 8)}…` : documentId; +} + +function CaseMetricsWidget() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [pluginData, setPluginData] = useState>({state: "loading"}); + + useEffect(() => { + sdk + .getPluginData("/summary") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setPluginData({state: "ready", data: res.body as SummaryData}); + } else { + setPluginData({state: "error", message: sdk.t("caseMetricsWidget.error")}); + } + }) + .catch((err) => setPluginData({state: "error", message: String(err?.message ?? err)})); + }, []); + + useResizeEmitter([pluginData]); + + return ( +
+
{sdk.t("caseMetricsWidget.title")}
+
{sdk.t("caseMetricsWidget.hello")}
+ + {pluginData.state === "loading" &&
{sdk.t("caseMetricsWidget.loading")}
} + {pluginData.state === "error" &&
{pluginData.message}
} + {pluginData.state === "ready" && ( +
+
+
{sdk.t("caseMetricsWidget.viewCount")}
+
{pluginData.data.viewCount}
+
+
+
{sdk.t("caseMetricsWidget.currency")}
+
{pluginData.data.currency}
+
+
+
{sdk.t("caseMetricsWidget.documentId")}
+
{shortId(documentId)}
+
+
+ )} +
+ ); +} + +// Wait for the SDK to receive the manifest + init (context) before mounting, so sdk.t and +// sdk.getContext() are populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget.html b/plugin-host/sample-plugins/case-summary/frontend/case-widget.html new file mode 100644 index 0000000000..2ee27db552 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Widget + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx new file mode 100644 index 0000000000..687e49d84c --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx @@ -0,0 +1,158 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const cardStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "16px", + fontSize: "14px", + color: "#393939", +}; + +const titleRowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "8px", +}; + +const titleStyle: React.CSSProperties = {fontSize: "14px", fontWeight: 600, color: "#161616"}; +const mutedStyle: React.CSSProperties = {color: "#6f6f6f"}; +const errorStyle: React.CSSProperties = {color: "#da1e28"}; +const badgeStyle: React.CSSProperties = { + fontSize: "12px", + color: "#6f6f6f", + background: "#e0e0e0", + borderRadius: "12px", + padding: "2px 10px", +}; +const rowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + padding: "4px 0", + borderBottom: "1px solid #f4f4f4", +}; + +interface SummaryData { + message: string; + viewCount: number; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +/** The Angular parent has no host-side resize handling for widgets; still emit so future surfaces can use it. */ +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + sdk.emit("resize", {height: document.documentElement.scrollHeight}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function CaseWidget() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [pluginData, setPluginData] = useState>({state: "loading"}); + const [definition, setDefinition] = useState>({state: "loading"}); + + // Plugin-served data (via the plugin's own handle_request handler over the /data route). + useEffect(() => { + sdk + .getPluginData("/summary") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setPluginData({state: "ready", data: res.body as SummaryData}); + } else { + setPluginData({state: "error", message: sdk.t("caseWidget.error")}); + } + }) + .catch((err) => setPluginData({state: "error", message: String(err?.message ?? err)})); + }, []); + + // Valtimo case data, scoped to the logged-in user (GZAC via the downscoped user token). + useEffect(() => { + if (!documentId) { + setDefinition({state: "error", message: sdk.t("caseWidget.error")}); + return; + } + sdk + .callValtimo("GET", `/api/v1/document/${documentId}`) + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setDefinition({state: "ready", data: describeDefinition(res.body as Record)}); + } else { + setDefinition({state: "error", message: sdk.t("caseWidget.error")}); + } + }) + .catch((err) => setDefinition({state: "error", message: String(err?.message ?? err)})); + }, [documentId]); + + useResizeEmitter([pluginData, definition]); + + return ( +
+
+ {sdk.t("caseWidget.title")} + {pluginData.state === "ready" && ( + + {sdk.t("caseWidget.viewCount")}: {pluginData.data.viewCount} + + )} +
+ +
{sdk.t("caseWidget.hello")}
+ + {pluginData.state === "loading" &&
{sdk.t("caseWidget.loading")}
} + {pluginData.state === "error" &&
{pluginData.message}
} + {pluginData.state === "ready" &&
{pluginData.data.message}
} + +
+ {sdk.t("caseWidget.definition")} + + {definition.state === "loading" && sdk.t("caseWidget.loading")} + {definition.state === "error" && {definition.message}} + {definition.state === "ready" && definition.data} + +
+
+ ); +} + +function describeDefinition(document: Record): string { + const definitionId = document.definitionId as + | {name?: string; blueprintId?: {blueprintVersionTag?: string}} + | undefined; + if (!definitionId) return "(unknown)"; + const name = definitionId.name ?? "unknown"; + const version = definitionId.blueprintId?.blueprintVersionTag; + return version ? `${name} v${version}` : name; +} + +// Wait for the SDK to receive the manifest + init (context) before mounting, so sdk.t and +// sdk.getContext() are populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/config.html b/plugin-host/sample-plugins/case-summary/frontend/config.html new file mode 100644 index 0000000000..71e16c12f7 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/config.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Configuration + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/config.tsx b/plugin-host/sample-plugins/case-summary/frontend/config.tsx new file mode 100644 index 0000000000..2f025a2f75 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/config.tsx @@ -0,0 +1,116 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { ValtimoPluginSDK } from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 16px", + fontSize: "14px", + border: "1px solid #8d8d8d", + borderRadius: "0", + backgroundColor: "#f4f4f4", + outline: "none", + boxSizing: "border-box", +}; + +const labelStyle: React.CSSProperties = { + display: "block", + marginBottom: "4px", + fontSize: "12px", + color: "#525252", +}; + +const helpTextStyle: React.CSSProperties = { + fontSize: "12px", + color: "#6f6f6f", + marginTop: "4px", +}; + +function ConfigForm() { + const [title, setTitle] = useState(""); + const [currency, setCurrency] = useState("EUR"); + + useEffect(() => { + sdk.onPrefillConfiguration(({ title: prefillTitle, configuration }) => { + if (prefillTitle) setTitle(prefillTitle); + if (configuration.currency) setCurrency(configuration.currency as string); + }); + + sdk.onSave(() => { + // No-op: parent already has the latest data via configurationChanged + }); + + sdk.emit("ready", {}); + }, []); + + const emitConfig = useCallback((newTitle: string, newCurrency: string) => { + const valid = newTitle.trim().length > 0; + sdk.setConfiguration(valid, newTitle.trim(), { + currency: newCurrency.trim() || "EUR", + }); + }, []); + + const handleTitleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setTitle(val); + emitConfig(val, currency); + }; + + const handleCurrencyChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setCurrency(val); + emitConfig(title, val); + }; + + return ( +
+
+ + +
+ +
+ + +

{sdk.t("config.currency.help")}

+
+
+ ); +} + +// Wait for the SDK to fetch the manifest before mounting; until then `sdk.t(key)` returns the +// raw key, which flashes on screen. Bootstrap once translations are available. +sdk.ready().then(() => { + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/page-overview.html b/plugin-host/sample-plugins/case-summary/frontend/page-overview.html new file mode 100644 index 0000000000..108a352527 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/page-overview.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Overview + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/page-overview.tsx b/plugin-host/sample-plugins/case-summary/frontend/page-overview.tsx new file mode 100644 index 0000000000..7e2bbcdb3c --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/page-overview.tsx @@ -0,0 +1,139 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const pageStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "24px", + maxWidth: "880px", +}; + +const panelStyle: React.CSSProperties = { + border: "1px solid #e0e0e0", + padding: "16px", + marginBottom: "16px", + background: "#ffffff", +}; + +const panelTitleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + padding: "4px 0", + fontSize: "14px", + color: "#393939", + borderBottom: "1px solid #f4f4f4", +}; + +const mutedStyle: React.CSSProperties = {color: "#6f6f6f", fontSize: "14px"}; +const errorStyle: React.CSSProperties = {color: "#da1e28", fontSize: "14px"}; + +interface OverviewData { + message: string; + configurationId: string | null; + stats: Array<{label: string; value: string}>; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + const height = document.documentElement.scrollHeight; + sdk.emit("resize", {height}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function OverviewPage() { + const ctx = sdk.getContext() ?? {}; + const configurationId = (ctx.configurationId as string | undefined) ?? null; + + const [overview, setOverview] = useState>({state: "loading"}); + + // Plugin-served data — fetched from the plugin's own `/overview` handle_request handler. + useEffect(() => { + sdk + .getPluginData("/overview") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setOverview({state: "ready", data: res.body as OverviewData}); + } else { + setOverview({state: "error", message: sdk.t("page.error")}); + } + }) + .catch((err) => setOverview({state: "error", message: String(err?.message ?? err)})); + }, []); + + useResizeEmitter([overview]); + + return ( +
+ {/* Hello world — static text via the translation table. */} +
+
{sdk.t("page.overview.hello.title")}
+
{sdk.t("page.overview.hello")}
+
+ + {/* Page context — a page carries the plugin configuration id (no document). */} +
+
{sdk.t("page.overview.context.title")}
+
+ {sdk.t("page.overview.context.configuration")} + {configurationId ?? "—"} +
+
+ + {/* Plugin-served overview stats. */} +
+
{sdk.t("page.overview.stats.title")}
+ {overview.state === "loading" &&
{sdk.t("page.loading")}
} + {overview.state === "error" &&
{overview.message}
} + {overview.state === "ready" && ( +
+
{overview.data.message}
+ {overview.data.stats.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ )} +
+
+ ); +} + +// Wait for the SDK to fetch the manifest + receive init (context) before mounting. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/page-reports.html b/plugin-host/sample-plugins/case-summary/frontend/page-reports.html new file mode 100644 index 0000000000..aa25af8f15 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/page-reports.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Reports + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/page-reports.tsx b/plugin-host/sample-plugins/case-summary/frontend/page-reports.tsx new file mode 100644 index 0000000000..727e951969 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/page-reports.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const pageStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "24px", + maxWidth: "880px", +}; + +const panelStyle: React.CSSProperties = { + border: "1px solid #e0e0e0", + padding: "16px", + marginBottom: "16px", + background: "#ffffff", +}; + +const panelTitleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; + +const mutedStyle: React.CSSProperties = {color: "#6f6f6f", fontSize: "14px"}; +const errorStyle: React.CSSProperties = {color: "#da1e28", fontSize: "14px"}; + +const cellStyle: React.CSSProperties = { + padding: "8px 12px", + fontSize: "14px", + color: "#393939", + borderBottom: "1px solid #f4f4f4", + textAlign: "left", +}; + +const headerCellStyle: React.CSSProperties = { + ...cellStyle, + fontWeight: 600, + color: "#161616", +}; + +interface ReportRow { + period: string; + created: number; + completed: number; +} + +interface ReportsData { + rows: ReportRow[]; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + const height = document.documentElement.scrollHeight; + sdk.emit("resize", {height}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function ReportsPage() { + const [reports, setReports] = useState>({state: "loading"}); + + // Plugin-served data — fetched from the plugin's own `/reports` handle_request handler. + useEffect(() => { + sdk + .getPluginData("/reports") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setReports({state: "ready", data: res.body as ReportsData}); + } else { + setReports({state: "error", message: sdk.t("page.error")}); + } + }) + .catch((err) => setReports({state: "error", message: String(err?.message ?? err)})); + }, []); + + useResizeEmitter([reports]); + + return ( +
+
+
{sdk.t("page.reports.title")}
+
{sdk.t("page.reports.intro")}
+
+ +
+ {reports.state === "loading" &&
{sdk.t("page.loading")}
} + {reports.state === "error" &&
{reports.message}
} + {reports.state === "ready" && ( + + + + + + + + + + {reports.data.rows.map((row) => ( + + + + + + ))} + +
{sdk.t("page.reports.table.period")}{sdk.t("page.reports.table.created")}{sdk.t("page.reports.table.completed")}
{row.period}{row.created}{row.completed}
+ )} +
+
+ ); +} + +// Wait for the SDK to fetch the manifest before mounting, so `sdk.t(key)` is populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.html b/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.html new file mode 100644 index 0000000000..195645fcd3 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Task Form (Level 0) + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.tsx b/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.tsx new file mode 100644 index 0000000000..c74436b1c5 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/task-form-approve.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; +import { + buttonDisabledStyle, + buttonStyle, + Decision, + errorStyle, + labelStyle, + mutedStyle, + panelStyle, + panelTitleStyle, + radioLabelStyle, + rootStyle, + textareaStyle, +} from "./task-form-shared"; + +const sdk = new ValtimoPluginSDK(); + +type SubmitState = + | {state: "editing"} + | {state: "submitting"} + | {state: "completed"} + | {state: "error"; message: string}; + +/** + * Level 0 — a pure task form with **no plugin backend code at all**. It collects the input and calls + * `sdk.submitTask(data)` with value-resolver-prefixed keys (`pv:…` → process variable, `doc:/…` → + * case document field). The Angular parent submits to GZAC, which resolves the values and completes + * the task the standard way. No `request()` handler, no `permissions.endpoints`, no user token. + */ +function TaskForm() { + const [decision, setDecision] = useState("approve"); + const [comment, setComment] = useState(""); + const [submit, setSubmit] = useState({state: "editing"}); + + useEffect(() => { + sdk.emit("resize", {height: document.documentElement.scrollHeight}); + }, [submit, comment, decision]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmit({state: "submitting"}); + try { + const result = await sdk.submitTask({ + // Value-resolver prefixes GZAC already understands — no backend code needed to route these. + "pv:caseApproved": decision === "approve", + "doc:/reviewComment": comment.trim(), + }); + if (result.ok) { + setSubmit({state: "completed"}); + } else { + setSubmit({state: "error", message: result.errors?.[0] ?? sdk.t("taskForm.error")}); + } + } catch (err) { + setSubmit({state: "error", message: String((err as Error)?.message ?? err)}); + } + }; + + if (submit.state === "completed") { + return ( +
+
+
{sdk.t("taskForm.completed.title")}
+
{sdk.t("taskForm.completed")}
+
+
+ ); + } + + const submitting = submit.state === "submitting"; + + return ( +
+
+
{sdk.t("taskForm.approve.title")}
+
{sdk.t("taskForm.approve.intro")}
+ +
+ {sdk.t("taskForm.decision.label")} + + +
+ +
+ +