From 32e7e12d8f980961c5fc11d28fc8f7bed710d7e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:03:51 +0200 Subject: [PATCH 01/22] chore(release): sync beta back into development (#1775) Co-authored-by: Ruben van der Linde Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> From 3edc506d8b69c84d2b22a5bc2e4b616a2913ae57 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:04:04 +0200 Subject: [PATCH 02/22] chore(release): 0.3.14-unstable.20260831113824 (#1773) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9ed3f9871..9a303be06 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -23,7 +23,7 @@ - 📋 Pas bedrijfsregels toe op endpoint-verkeer en houd een audit trail per object bij ]]> - 0.3.13-unstable.20260831053109 + 0.3.14-unstable.20260831113824 EUPL-1.2 Conduction Integriq diff --git a/openapi.json b/openapi.json index 6de7d4b06..c499c18dc 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "integriq", - "version": "0.3.13-unstable.20260831053109", + "version": "0.3.14-unstable.20260831113824", "description": "open connector", "license": { "name": "EUPL-1.2" From 047189e8b32e3c85d1f284ce5cc9fb8790786912 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 18:10:55 +0200 Subject: [PATCH 03/22] fix(deps): make the npm overrides follow their direct dependency (#1787) Dependabot cannot update these packages. Its npm updater fails with: npm error code EOVERRIDE npm error Override for conflicts with direct dependency The package is listed BOTH as a direct dependency and as a literal overrides entry pinned to the same range. Dependabot bumps the direct dependency and does not move the override, npm rejects the mismatched pair, and the update is abandoned -- so these packages can never be updated, security advisories included. npm's self-reference is the idiom for exactly this: $ tells the override to follow whatever the direct dependency resolves to, instead of a range that has to be kept in lockstep by hand. This repo already uses it (see the vue entry under @vue/test-utils). Transitive consumers still get pinned to one version -- the direct dependency's -- which is what the override was there to do. What changes is that the pin tracks the dependency instead of duplicating it. Verified in a clean container: bumping the direct dependency with the literal override reproduces the EOVERRIDE above; with the self-reference the same bump resolves. Co-authored-by: Conduction Release Bot --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4df10a22..2abcc515b 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "@vue/test-utils": { "vue": "$vue" }, - "@nextcloud/axios": "~2.5.2", + "@nextcloud/axios": "$@nextcloud/axios", "postcss": "^8.4.49", "vue3-apexcharts": "<1.9.0", "vue-apexcharts": "<1.9.0" From 5f70bf33b5303faacb7f8a3ad71243c8ea0a9e73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:58:20 +0200 Subject: [PATCH 04/22] chore(deps-dev): bump jest-environment-jsdom from 29.7.0 to 30.5.0 (#1782) Bumps [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) from 29.7.0 to 30.5.0. - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.5.0/packages/jest-environment-jsdom) --- updated-dependencies: - dependency-name: jest-environment-jsdom dependency-version: 30.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 2029 +++++++++++++++------------------------------ package.json | 2 +- 2 files changed, 660 insertions(+), 1371 deletions(-) diff --git a/package-lock.json b/package-lock.json index d26b88dda..faa9a36b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "eslint-webpack-plugin": "^6.0.0", "espree": "^11.0.0", "jest": "^30.5.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "node-polyfill-webpack-plugin": "4.0.0", "postcss-html": "^1.0.0", @@ -89,6 +89,94 @@ "npm": "^11.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/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, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2200,6 +2288,26 @@ "node": ">= 18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-calc": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", @@ -2224,6 +2332,58 @@ "@csstools/css-tokenizer": "^4.0.0" } }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", @@ -3057,19 +3217,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jest/console/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/console/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3086,28 +3233,6 @@ "node": ">=8" } }, - "node_modules/@jest/console/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/console/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -3126,22 +3251,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/console/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/core": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.0.tgz", @@ -3229,19 +3338,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/core/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3258,28 +3354,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/core/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -3298,22 +3372,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/diff-sequences": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", @@ -3325,109 +3383,189 @@ } }, "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "^29.7.0" + "jest-mock": "30.5.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect": { + "node_modules/@jest/environment-jsdom-abstract": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.0.tgz", - "integrity": "sha512-jEmgmgJEobJ3zEhDOGp1VAJ6JkoVelpS8uZ1ae1Ul/5lP78UKJKmmU0lciJwd6JdnqOXHaaS/QCKwbf1dHI9MA==", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.0.tgz", + "integrity": "sha512-825vac4Dmysbn2kU7VUQPoKuj/HNUpSTgv98KCByMOSPvHuj1/HpVZeLRsP/itDB2HFiDcoTUrsg8fSu3PxKBw==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.5.0", - "jest-snapshot": "30.5.0" + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/jsdom": "*", + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/@jest/expect-utils": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", - "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/get-type": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", - "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals": { + "node_modules/@jest/environment/node_modules/@jest/schemas": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.0.tgz", - "integrity": "sha512-h7eJx534czwL8lQMYB0hwLT4/HquO8EX/RtYL7RNUHyUyWWVciYjoudN4Ns5JmNvn2/jh0Vm9UstZjEzJJ5EsQ==", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.5.0", - "@jest/expect": "30.5.0", - "@jest/types": "30.5.0", - "jest-mock": "30.5.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/environment": { + "node_modules/@jest/environment/node_modules/@jest/types": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", - "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.5.0", - "@jest/types": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "jest-mock": "30.5.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/expect": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.0.tgz", + "integrity": "sha512-jEmgmgJEobJ3zEhDOGp1VAJ6JkoVelpS8uZ1ae1Ul/5lP78UKJKmmU0lciJwd6JdnqOXHaaS/QCKwbf1dHI9MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.5.0", + "jest-snapshot": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/fake-timers": { + "node_modules/@jest/fake-timers": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", @@ -3445,7 +3583,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/schemas": { + "node_modules/@jest/fake-timers/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", @@ -3458,7 +3596,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/types": { + "node_modules/@jest/fake-timers/node_modules/@jest/types": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", @@ -3477,37 +3615,14 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@sinclair/typebox": { + "node_modules/@jest/fake-timers/node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, - "node_modules/@jest/globals/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@jest/globals/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/globals/node_modules/ci-info": { + "node_modules/@jest/fake-timers/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", @@ -3523,78 +3638,89 @@ "node": ">=8" } }, - "node_modules/@jest/globals/node_modules/jest-message-util": { + "node_modules/@jest/fake-timers/node_modules/jest-util": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", + "@types/node": "*", "chalk": "^4.1.2", + "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/jest-mock": { + "node_modules/@jest/get-type": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.0.tgz", + "integrity": "sha512-h7eJx534czwL8lQMYB0hwLT4/HquO8EX/RtYL7RNUHyUyWWVciYjoudN4Ns5JmNvn2/jh0Vm9UstZjEzJJ5EsQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.5.0", + "@jest/environment": "30.5.0", + "@jest/expect": "30.5.0", "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" + "jest-mock": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/jest-util": { + "node_modules/@jest/globals/node_modules/@jest/schemas": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", - "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.5.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/pretty-format": { + "node_modules/@jest/globals/node_modules/@jest/types": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/pattern": "30.5.0", "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/globals/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/pattern": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", @@ -3707,19 +3833,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@jest/reporters/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3736,28 +3849,6 @@ "node": ">=8" } }, - "node_modules/@jest/reporters/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/reporters/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -3793,22 +3884,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/reporters/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -4901,31 +4976,16 @@ "node": ">= 14" } }, - "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { "node": ">= 14" @@ -5685,13 +5745,13 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@standard-schema/spec": { @@ -5824,16 +5884,6 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -6003,55 +6053,6 @@ "pretty-format": "^30.0.0" } }, - "node_modules/@types/jest/node_modules/@jest/schemas": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", - "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@types/jest/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@types/jquery": { "version": "3.5.16", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", @@ -6062,9 +6063,9 @@ } }, "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { @@ -7505,14 +7506,6 @@ "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", "license": "MIT" }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/abbrev": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", @@ -7548,17 +7541,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -7569,19 +7551,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -9375,33 +9344,20 @@ "node": ">=4" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -9523,18 +9479,17 @@ } }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/date-fns": { @@ -9867,20 +9822,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -10207,28 +10148,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", @@ -10797,19 +10716,6 @@ "dev": true, "license": "MIT" }, - "node_modules/expect/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/expect/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -10826,44 +10732,6 @@ "node": ">=8" } }, - "node_modules/expect/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/expect/node_modules/jest-mock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/expect/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -10882,22 +10750,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/expect/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -11934,16 +11786,16 @@ "license": "MIT" }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-entities": { @@ -12025,18 +11877,27 @@ "optional": true }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/https-browserify": { @@ -12834,40 +12695,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/@jest/environment": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", - "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-mock": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus/node_modules/@jest/fake-timers": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", - "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "jest-message-util": "30.5.0", - "jest-mock": "30.5.0", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-circus/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", @@ -12907,33 +12734,10 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-circus/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "node_modules/jest-circus/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -12946,44 +12750,6 @@ "node": ">=8" } }, - "node_modules/jest-circus/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-mock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-circus/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -13002,22 +12768,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-cli": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.0.tgz", @@ -13214,19 +12964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-config/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -13261,22 +12998,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-diff": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.0.tgz", @@ -13293,55 +13014,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/@jest/schemas": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", - "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-diff/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-docblock": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", @@ -13411,19 +13083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-each/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -13458,43 +13117,23 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/pretty-format": { + "node_modules/jest-environment-jsdom": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.0.tgz", + "integrity": "sha512-VVHN/G3zrxsQR398jvMalM76ALX6YBAftsYLCGtTeKRmz4f42YJAP05AGpk0VF5SLtoHdkfKunYGfnKPnmcEOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@jest/environment": "30.5.0", + "@jest/environment-jsdom-abstract": "30.5.0", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -13521,40 +13160,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node/node_modules/@jest/environment": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", - "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-mock": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/@jest/fake-timers": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", - "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "jest-message-util": "30.5.0", - "jest-mock": "30.5.0", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-environment-node/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", @@ -13594,29 +13199,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-environment-node/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/jest-environment-node/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-environment-node/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -13633,87 +13215,33 @@ "node": ">=8" } }, - "node_modules/jest-environment-node/node_modules/jest-message-util": { + "node_modules/jest-environment-node/node_modules/jest-util": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", + "@types/node": "*", "chalk": "^4.1.2", + "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node/node_modules/jest-mock": { + "node_modules/jest-haste-map": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.0.tgz", + "integrity": "sha512-0FStogBslBVOEqTOJr4oXMtFitmrWp9WscG6Gbns88i0YAuMXijCT2G5VMfg/HCR4QAnL+OF2C2ednag+HlDuA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.5.0", "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/jest-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", - "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.0.tgz", - "integrity": "sha512-0FStogBslBVOEqTOJr4oXMtFitmrWp9WscG6Gbns88i0YAuMXijCT2G5VMfg/HCR4QAnL+OF2C2ednag+HlDuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@parcel/watcher": "^2.6.0", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", @@ -13848,7 +13376,45 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/@jest/schemas": { + "node_modules/jest-matcher-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.0.tgz", + "integrity": "sha512-EfaYMC9f9ds7fahB/LYFTgd1Z2RS9Vpm2e46gazij0onkpoQG7Daq+MLm8/gQVqWwRVjL/RNDggbFx9MsrJEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.0", + "pretty-format": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.0", + "picomatch": "^4.0.3", + "pretty-format": "30.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", @@ -13861,59 +13427,83 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/@sinclair/typebox": { + "node_modules/jest-message-util/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-message-util/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { + "node_modules/jest-message-util/node_modules/jest-util": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@jest/types": "30.5.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils": { + "node_modules/jest-mock": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.0.tgz", - "integrity": "sha512-EfaYMC9f9ds7fahB/LYFTgd1Z2RS9Vpm2e46gazij0onkpoQG7Daq+MLm8/gQVqWwRVjL/RNDggbFx9MsrJEmQ==", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "chalk": "^4.1.2", - "jest-diff": "30.5.0", - "pretty-format": "30.5.0" + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "node_modules/jest-mock/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", @@ -13926,76 +13516,64 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "node_modules/jest-mock/node_modules/@jest/types": { "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/pattern": "30.5.0", "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "node_modules/jest-mock/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/jest-mock/node_modules/jest-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-util": "^29.7.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-regex-util": { @@ -14148,40 +13726,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/@jest/environment": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", - "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-mock": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner/node_modules/@jest/fake-timers": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", - "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "jest-message-util": "30.5.0", - "jest-mock": "30.5.0", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-runner/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", @@ -14221,29 +13765,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-runner/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-runner/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -14260,44 +13781,6 @@ "node": ">=8" } }, - "node_modules/jest-runner/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner/node_modules/jest-mock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-runner/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -14333,22 +13816,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-runner/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -14400,40 +13867,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/@jest/environment": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", - "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-mock": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime/node_modules/@jest/fake-timers": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", - "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.5.0", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "jest-message-util": "30.5.0", - "jest-mock": "30.5.0", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-runtime/node_modules/@jest/schemas": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", @@ -14466,36 +13899,13 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-runtime/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/jest-runtime/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, + "node_modules/jest-runtime/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-runtime/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -14512,44 +13922,6 @@ "node": ">=8" } }, - "node_modules/jest-runtime/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime/node_modules/jest-mock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", - "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.5.0", - "@jest/types": "30.5.0", - "@types/node": "*", - "jest-util": "30.5.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-runtime/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -14568,22 +13940,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-snapshot": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.0.tgz", @@ -14656,19 +14012,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-snapshot/node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -14685,28 +14028,6 @@ "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/jest-message-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", - "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.0", - "picomatch": "^4.0.3", - "pretty-format": "30.5.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-snapshot/node_modules/jest-util": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", @@ -14725,22 +14046,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -14849,19 +14154,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -14875,22 +14167,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", - "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-watcher": { "version": "30.5.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.0.tgz", @@ -15116,44 +14392,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -15161,14 +14431,28 @@ } } }, - "node_modules/jsdom/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/jsesc": { @@ -17424,9 +16708,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", - "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "version": "2.2.26", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.26.tgz", + "integrity": "sha512-LPmrA6J31t9ac9294T/WZV6AK5CUcFZ+VXQ7pNNc1+JAjeKthW7vqekOBu6pXzsxD7slwJ7M4XMhamojG3T4oA==", "dev": true, "license": "MIT" }, @@ -18413,20 +17697,41 @@ } }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/pretty-format/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -18592,19 +17897,6 @@ "node": ">=10" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -18827,13 +18119,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -19277,6 +18562,13 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -21131,6 +20423,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -21177,32 +20489,29 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tributejs": { @@ -21717,16 +21026,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unplugin": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", @@ -22393,26 +21692,16 @@ "license": "MIT" }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/watchpack": { @@ -22652,9 +21941,9 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", @@ -22662,31 +21951,31 @@ "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { diff --git a/package.json b/package.json index 2abcc515b..db7c69ee3 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "eslint-webpack-plugin": "^6.0.0", "espree": "^11.0.0", "jest": "^30.5.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "node-polyfill-webpack-plugin": "4.0.0", "postcss-html": "^1.0.0", From ff3e078c2f004bff825138b47963099e2ed8e38e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:58:25 +0200 Subject: [PATCH 05/22] chore(deps-dev): bump @pinia/testing from 0.1.7 to 2.0.1 (#1781) Bumps [@pinia/testing](https://github.com/vuejs/pinia) from 0.1.7 to 2.0.1. - [Release notes](https://github.com/vuejs/pinia/releases) - [Commits](https://github.com/vuejs/pinia/compare/@pinia/testing@0.1.7...@pinia/testing@2.0.1) --- updated-dependencies: - dependency-name: "@pinia/testing" dependency-version: 2.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index faa9a36b2..a9465c4b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^6.0.1", - "@pinia/testing": "^0.1.3", + "@pinia/testing": "^2.0.1", "@playwright/test": "^1.59.1", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.14", @@ -5384,19 +5384,19 @@ } }, "node_modules/@pinia/testing": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.7.tgz", - "integrity": "sha512-xcDq6Ry/kNhZ5bsUMl7DeoFXwdume1NYzDggCiDUDKoPQ6Mo0eH9VU7bJvBtlurqe6byAntWoX5IhVFqWzRz/Q==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-2.0.1.tgz", + "integrity": "sha512-pQ4a4SCzdiLrhM4yv/TQG2ufNmktNlLZjjN6X/qMhXvZIiDN3L4L4WrChmcufX2LIarbl/ZPWOty1RB5KZIb2A==", "dev": true, "license": "MIT", "dependencies": { - "vue-demi": "^0.14.10" + "nostics": "^1.1.4" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "pinia": ">=2.2.6" + "pinia": ">=4.0.2" } }, "node_modules/@pkgjs/parseargs": { diff --git a/package.json b/package.json index db7c69ee3..c7c1df6f4 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^6.0.1", - "@pinia/testing": "^0.1.3", + "@pinia/testing": "^2.0.1", "@playwright/test": "^1.59.1", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.14", From f7ab58f5b81b84ce8b469a9d2b6c05070abd0696 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:58:29 +0200 Subject: [PATCH 06/22] chore(deps-dev): bump sass-loader from 16.0.8 to 17.0.0 (#1780) Bumps [sass-loader](https://github.com/webpack/sass-loader) from 16.0.8 to 17.0.0. - [Release notes](https://github.com/webpack/sass-loader/releases) - [Changelog](https://github.com/webpack/sass-loader/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/sass-loader/compare/v16.0.8...v17.0.0) --- updated-dependencies: - dependency-name: sass-loader dependency-version: 17.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 17 +++++------------ package.json | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9465c4b9..68dfa53ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "postcss-html": "^1.0.0", "prettier": "^3.9.6", "sass": "^1.77.0", - "sass-loader": "^16.0.2", + "sass-loader": "^17.0.0", "stylelint": "^17.9.1", "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-vue": "^1.6.1", @@ -18661,16 +18661,13 @@ } }, "node_modules/sass-loader": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.8.tgz", - "integrity": "sha512-hcov4ZwZJIGbEuyNr9EmiTmZueyrxSToE6GOzoZnq5JM7ecRO7ttyvilPn+VmRsqiP16+VYZzVnGZj/hzZgKBA==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-17.0.0.tgz", + "integrity": "sha512-0Ybm8ohBQ9LcrycVrFQp/KQBNX5a3Wda9/smS0mE/xLffzEnwvV8nykOzrbiSWNzTE3IB/jiXx8O4QmDPG2+Gw==", "dev": true, "license": "MIT", - "dependencies": { - "neo-async": "^2.6.2" - }, "engines": { - "node": ">= 18.12.0" + "node": ">= 22.11.0" }, "funding": { "type": "opencollective", @@ -18678,7 +18675,6 @@ }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" @@ -18687,9 +18683,6 @@ "@rspack/core": { "optional": true }, - "node-sass": { - "optional": true - }, "sass": { "optional": true }, diff --git a/package.json b/package.json index c7c1df6f4..c33721ed2 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "postcss-html": "^1.0.0", "prettier": "^3.9.6", "sass": "^1.77.0", - "sass-loader": "^16.0.2", + "sass-loader": "^17.0.0", "stylelint": "^17.9.1", "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-vue": "^1.6.1", From 00c60164e548c188bd6048a18f294dcbd7028363 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:58:34 +0200 Subject: [PATCH 07/22] chore(deps-dev): bump node-polyfill-webpack-plugin from 4.0.0 to 4.1.0 (#1777) Bumps [node-polyfill-webpack-plugin](https://github.com/Richienb/node-polyfill-webpack-plugin) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/Richienb/node-polyfill-webpack-plugin/releases) - [Commits](https://github.com/Richienb/node-polyfill-webpack-plugin/compare/v4.0.0...v4.1.0) --- updated-dependencies: - dependency-name: node-polyfill-webpack-plugin dependency-version: 4.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 271 +++++++++++++++++++--------------------------- package.json | 2 +- 2 files changed, 112 insertions(+), 161 deletions(-) diff --git a/package-lock.json b/package-lock.json index 68dfa53ea..0aab0fb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "jest": "^30.5.0", "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", - "node-polyfill-webpack-plugin": "4.0.0", + "node-polyfill-webpack-plugin": "4.1.0", "postcss-html": "^1.0.0", "prettier": "^3.9.6", "sass": "^1.77.0", @@ -7516,19 +7516,6 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "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, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -8115,48 +8102,6 @@ "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" - } - ], - "license": "MIT", - "optional": true, - "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, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/blurhash": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", @@ -8210,6 +8155,16 @@ "dev": true, "license": "MIT" }, + "node_modules/browser-resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", + "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.17.0" + } + }, "node_modules/browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -8401,9 +8356,9 @@ } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -8422,7 +8377,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "ieee754": "^1.1.13" } }, "node_modules/buffer-from": { @@ -9204,6 +9159,13 @@ "sha.js": "^2.4.8" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", @@ -9797,13 +9759,13 @@ } }, "node_modules/domain-browser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-5.7.0.tgz", - "integrity": "sha512-edTFu0M/7wO1pXY6GDxVNVW086uqwWYIHP98txhcPyV995X21JIH2DtYp33sQJOupYoXKe9RwTw2Ya2vWaquTQ==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", + "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", "dev": true, - "license": "Artistic-2.0", + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=10" }, "funding": { "url": "https://bevry.me/fund" @@ -10567,16 +10529,6 @@ "node": ">=0.10.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, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -12448,6 +12400,16 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-timers-promises": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", + "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -16560,36 +16522,14 @@ "license": "MIT" }, "node_modules/node-polyfill-webpack-plugin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-4.0.0.tgz", - "integrity": "sha512-WLk77vLpbcpmTekRj6s6vYxk30XoyaY5MDZ4+9g8OaKoG3Ij+TjOqhpQjVUlfDZBPBgpNATDltaQkzuXSnnkwg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-4.1.0.tgz", + "integrity": "sha512-b4ei444EKkOagG/yFqojrD3QTYM5IOU1f8tn9o6uwrG4qL+brI7oVhjPVd0ZL2xy+Z6CP5bu9w8XTvlWgiXHcw==", "dev": true, "license": "MIT", "dependencies": { - "assert": "^2.1.0", - "browserify-zlib": "^0.2.0", - "buffer": "^6.0.3", - "console-browserify": "^1.2.0", - "constants-browserify": "^1.0.0", - "crypto-browserify": "^3.12.0", - "domain-browser": "^5.7.0", - "events": "^3.3.0", - "https-browserify": "^1.0.0", - "os-browserify": "^0.3.0", - "path-browserify": "^1.0.1", - "process": "^0.11.10", - "punycode": "^2.3.1", - "querystring-es3": "^0.2.1", - "readable-stream": "^4.5.2", - "stream-browserify": "^3.0.0", - "stream-http": "^3.2.0", - "string_decoder": "^1.3.0", - "timers-browserify": "^2.0.12", - "tty-browserify": "^0.0.1", - "type-fest": "^4.18.2", - "url": "^0.11.3", - "util": "^0.12.5", - "vm-browserify": "^1.1.2" + "node-stdlib-browser": "^1.3.0", + "type-fest": "^4.27.0" }, "engines": { "node": ">=14" @@ -16621,6 +16561,65 @@ "node": ">=18" } }, + "node_modules/node-stdlib-browser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", + "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "browser-resolve": "^2.0.0", + "browserify-zlib": "^0.2.0", + "buffer": "^5.7.1", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "create-require": "^1.1.1", + "crypto-browserify": "^3.12.1", + "domain-browser": "4.22.0", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "isomorphic-timers-promises": "^1.0.1", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", + "pkg-dir": "^5.0.0", + "process": "^0.11.10", + "punycode": "^1.4.1", + "querystring-es3": "^0.2.1", + "readable-stream": "^3.6.0", + "stream-browserify": "^3.0.0", + "stream-http": "^3.2.0", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.1", + "url": "^0.11.4", + "util": "^0.12.4", + "vm-browserify": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-stdlib-browser/node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-stdlib-browser/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nopt": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", @@ -17979,9 +17978,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -18120,20 +18119,18 @@ } }, "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==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 6" } }, "node_modules/readdirp": { @@ -19317,21 +19314,6 @@ "readable-stream": "^3.5.0" } }, - "node_modules/stream-browserify/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, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/stream-http": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", @@ -19345,21 +19327,6 @@ "xtend": "^4.0.2" } }, - "node_modules/stream-http/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, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -20134,22 +20101,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/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, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index c33721ed2..e467d218b 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "jest": "^30.5.0", "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", - "node-polyfill-webpack-plugin": "4.0.0", + "node-polyfill-webpack-plugin": "4.1.0", "postcss-html": "^1.0.0", "prettier": "^3.9.6", "sass": "^1.77.0", From e303c689586fb0678a8f4c5ff31001168d27f97e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:12:41 +0200 Subject: [PATCH 08/22] chore(release): 0.3.15-unstable.20260831170603 (#1793) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9a303be06..9122197e0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -23,7 +23,7 @@ - 📋 Pas bedrijfsregels toe op endpoint-verkeer en houd een audit trail per object bij ]]> - 0.3.14-unstable.20260831113824 + 0.3.15-unstable.20260831170603 EUPL-1.2 Conduction Integriq diff --git a/openapi.json b/openapi.json index c499c18dc..adf97e81b 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "integriq", - "version": "0.3.14-unstable.20260831113824", + "version": "0.3.15-unstable.20260831170603", "description": "open connector", "license": { "name": "EUPL-1.2" From d5a07ed3668163ac2de33033982f054a7dede448 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 23:42:02 +0200 Subject: [PATCH 09/22] chore(register): drop the mock flow schema, openregister owns flow (#1794) The mock register declared a schema slugged `flow`, shadowing a core OpenRegister schema of the same name. Slugs are global on a shared OpenRegister: measured on the dev instance, id 1 (openregister, 14 properties) and id 1109 (integriq, 7). Integriq's real register never declared it, so this was mock-only. The three seed objects bound to it go with the schema. Co-authored-by: Conduction Release Bot --- lib/Settings/integriq_mock_register.json | 219 +---------------------- 1 file changed, 2 insertions(+), 217 deletions(-) diff --git a/lib/Settings/integriq_mock_register.json b/lib/Settings/integriq_mock_register.json index 03c485d42..3d1a0d9a6 100644 --- a/lib/Settings/integriq_mock_register.json +++ b/lib/Settings/integriq_mock_register.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "integriq demo data", - "version": "1.0.0", + "version": "1.1.0", "description": "Demo data covering every schema this app supplies, offered as the first step of the app's setup walkthrough. Generated from the schemas themselves, so every object satisfies the schema that will validate it." }, "x-openregister": { @@ -3004,141 +3004,7 @@ "appendOnly": false, "immutable": false }, - "flow": { - "slug": "flow", - "title": "Flow", - "icon": "Sitemap", - "version": "1.0.0", - "summary": "A declarative, ordered multi-step pipeline referencing existing Source/Mapping/Synchronization/Endpoint/Approval entities by id", - "description": "An ordered list of steps (call/mapping/synchronization/event/approval/branch), each a thin reference to an existing entity. Executed by FlowRunnerService, which calls the referenced entity's own existing service method — no step type reimplements that logic. See openspec/specs/flow-orchestration/spec.md REQ-001.", - "required": [ - "name", - "steps" - ], - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Canonical UUID assigned by OpenRegister", - "title": "UUID" - }, - "name": { - "type": "string", - "description": "Human-readable flow name", - "title": "Name" - }, - "description": { - "type": "string", - "description": "What this flow does", - "title": "Description" - }, - "isEnabled": { - "type": "boolean", - "default": true, - "description": "When false, cron/endpoint/event triggers skip this flow; a manual Run still executes it (matches job/synchronization isEnabled precedent)", - "title": "Enabled" - }, - "steps": { - "type": "array", - "title": "Steps", - "description": "Ordered list of flow steps. Execution order is each item's own `order` field (a stable identifier), not array position, so `branch` targets (nextStepOrder/defaultNextStepOrder) stay valid across insert/delete/reorder edits in the step-list editor. `order` values MUST be unique within a flow — FlowRunnerService::run() rejects a flow with duplicate step `order` values as a fatal configuration error before executing any step, and the step-list editor validates the same rule client-side before save.", - "items": { - "type": "object", - "required": [ - "order", - "type", - "onError" - ], - "properties": { - "order": { - "type": "integer", - "description": "Stable step identifier and default execution sequence (ascending). branch steps' nextStepOrder/defaultNextStepOrder reference this value, not array position. MUST be unique within the flow.", - "title": "Order" - }, - "type": { - "type": "string", - "enum": [ - "call", - "mapping", - "synchronization", - "event", - "approval", - "branch" - ], - "description": "Which existing service this step dispatches to: call -> CallService::call(), mapping -> MappingService::executeMapping(), synchronization -> SynchronizationService::synchronize(), event -> EventService::emitCloudEvent(), approval -> suspend/resume via ApprovalService, branch -> JsonLogic-selected next step (no service call).", - "title": "Type" - }, - "configRef": { - "type": "string", - "format": "uuid", - "description": "Id of the existing Source (call) / Mapping (mapping) / Synchronization (synchronization) entity this step invokes. Not applicable to event/approval (config carries their parameters) or branch steps.", - "title": "Config Reference" - }, - "condition": { - "type": "object", - "description": "Optional JsonLogic run-if rule, evaluated via JWadhams\\JsonLogic::apply() against the current step context. Step runs only when this evaluates loosely true. Absent/empty = always run.", - "title": "Condition" - }, - "onError": { - "type": "string", - "enum": [ - "stop", - "continue", - "dead_letter" - ], - "default": "stop", - "description": "stop: flow_run.status becomes stopped, no later step runs. continue: the run proceeds to the next step. dead_letter: flow_run.status becomes dead_letter (distinct from stopped), no later step runs.", - "title": "On Error" - }, - "config": { - "type": "object", - "description": "Type-specific parameters this step's dispatch needs beyond configRef: call={endpoint?, method?, requestConfig?}; event={source, subject?, type} (EventService::emitCloudEvent() args, design.md Decision 5); approval={approverGroup, onReject?, onTimeout?, ttlSeconds?} (mirrors the approval rule action's own config shape); synchronization={isTest?, force?, mutationType?} (optional overrides, default off so sync-safety guards are never bypassed by a flow step).", - "title": "Step Configuration" - }, - "branches": { - "type": "array", - "description": "branch steps only. Evaluated in array order via JsonLogic::apply(); the first matching entry's nextStepOrder is selected.", - "title": "Branches", - "items": { - "type": "object", - "properties": { - "condition": { - "type": "object", - "description": "JsonLogic rule for this branch", - "title": "Branch condition" - }, - "nextStepOrder": { - "type": "integer", - "description": "The order of the step to jump to when this branch's condition matches", - "title": "Next step order" - } - } - } - }, - "defaultNextStepOrder": { - "type": "integer", - "description": "branch steps only. Used when no branches[].condition matches. When absent, execution continues to the next step in order sequence.", - "title": "Default Next Step Order" - } - } - } - }, - "created": { - "type": "string", - "format": "date-time", - "description": "OR-managed creation timestamp", - "title": "Created" - }, - "updated": { - "type": "string", - "format": "date-time", - "description": "OR-managed update timestamp", - "title": "Updated" - } - }, - "appendOnly": false, - "immutable": false - }, + "flow_run": { "slug": "flow_run", "title": "Flow Run", @@ -8975,87 +8841,6 @@ "updated": "2026-03-03T09:00:00+00:00", "expires": "2026-03-03T09:00:00+00:00" }, - { - "@self": { - "register": "integriq", - "schema": "flow", - "slug": "flow-voorbeeld-name-1-1" - }, - "name": "Voorbeeld Name 1", - "steps": [ - { - "order": 1, - "type": "call", - "onError": "stop", - "configRef": "00000000-0000-4000-8000-000000000000", - "condition": {}, - "config": {}, - "branches": [ - {} - ], - "defaultNextStepOrder": 1 - } - ], - "uuid": "Voorbeeld Uuid 1", - "description": "Voorbeeld Description 1", - "isEnabled": true, - "created": "2026-03-01T09:00:00+00:00", - "updated": "2026-03-01T09:00:00+00:00" - }, - { - "@self": { - "register": "integriq", - "schema": "flow", - "slug": "flow-voorbeeld-name-2-2" - }, - "name": "Voorbeeld Name 2", - "steps": [ - { - "order": 2, - "type": "mapping", - "onError": "continue", - "configRef": "00000000-0000-4000-8000-000000000001", - "condition": {}, - "config": {}, - "branches": [ - {} - ], - "defaultNextStepOrder": 2 - } - ], - "uuid": "Voorbeeld Uuid 2", - "description": "Voorbeeld Description 2", - "isEnabled": true, - "created": "2026-03-02T09:00:00+00:00", - "updated": "2026-03-02T09:00:00+00:00" - }, - { - "@self": { - "register": "integriq", - "schema": "flow", - "slug": "flow-voorbeeld-name-3-3" - }, - "name": "Voorbeeld Name 3", - "steps": [ - { - "order": 3, - "type": "synchronization", - "onError": "dead_letter", - "configRef": "00000000-0000-4000-8000-000000000002", - "condition": {}, - "config": {}, - "branches": [ - {} - ], - "defaultNextStepOrder": 3 - } - ], - "uuid": "Voorbeeld Uuid 3", - "description": "Voorbeeld Description 3", - "isEnabled": true, - "created": "2026-03-03T09:00:00+00:00", - "updated": "2026-03-03T09:00:00+00:00" - }, { "@self": { "register": "integriq", From addaaf1b65e591d4374552684c9f1b88968a1cbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:29:02 +0200 Subject: [PATCH 10/22] chore(release): 0.3.16-unstable.20260831214826 (#1795) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9122197e0..6942e5ccf 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -23,7 +23,7 @@ - 📋 Pas bedrijfsregels toe op endpoint-verkeer en houd een audit trail per object bij ]]> - 0.3.15-unstable.20260831170603 + 0.3.16-unstable.20260831214826 EUPL-1.2 Conduction Integriq diff --git a/openapi.json b/openapi.json index adf97e81b..812593d62 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "integriq", - "version": "0.3.15-unstable.20260831170603", + "version": "0.3.16-unstable.20260831214826", "description": "open connector", "license": { "name": "EUPL-1.2" From 366856e61f681005f448b1578be58edcf89d28ab Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 11:46:00 +0200 Subject: [PATCH 11/22] docs(openspec): propose retiring Integriq's own flow engine (#1796) Integriq runs its own flow engine. visual-flow-orchestration declares a `flow` schema with an ordered steps[] pipeline, plus flow_run and flow_run_log, and FlowRunnerService executes it. OpenRegister runs one too, with a nodes/edges graph, triggers, an execution mode and 22 node types. The slug collision is the symptom. Measured on the dev instance 2026-08-31, `flow` is declared by both openregister and integriq, so a bare "$ref": "flow" resolves to whichever row SchemaMapper::find() fetches first. Renaming Integriq's slug clears that in an afternoon and leaves the fleet with two flow engines, which is the more expensive outcome: every later flow feature then has to be built twice. The gap that kept them separate is already being closed. OpenRegister has no node that makes an outbound call and none that runs a synchronization, which is Integriq's whole purpose. The existing integriq-flow-nodes change contributes both through RegisterFlowNodesEvent, the seam OpenRegister already ships. This change depends on it and adds what remains: an approval node, the steps-to-graph migration, retiring FlowRunnerService, and removing the schema. Proposal only. No code, and the pre-implementation gate holds three questions that must be answered before Task 1: PO sign-off on losing the step-list editor, whether an event-emit node exists, and how many live flow objects each instance carries. Found while building gate-106 (cross-app-schema-slug): generating its fleet baseline surfaced `flow` as the one remaining two-app claim that is a genuine shadowing of an OpenRegister core schema rather than two apps meaning different things by a common word. Co-authored-by: Conduction Release Bot --- .../retire-integriq-flow-schema/proposal.md | 108 ++++++++++++++++++ .../specs/flow-orchestration/spec.md | 63 ++++++++++ .../retire-integriq-flow-schema/tasks.md | 50 ++++++++ 3 files changed, 221 insertions(+) create mode 100644 openspec/changes/retire-integriq-flow-schema/proposal.md create mode 100644 openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md create mode 100644 openspec/changes/retire-integriq-flow-schema/tasks.md diff --git a/openspec/changes/retire-integriq-flow-schema/proposal.md b/openspec/changes/retire-integriq-flow-schema/proposal.md new file mode 100644 index 000000000..d9f4019ae --- /dev/null +++ b/openspec/changes/retire-integriq-flow-schema/proposal.md @@ -0,0 +1,108 @@ +--- +kind: code +depends_on: [integriq-flow-nodes] +--- + +# Proposal: retire-integriq-flow-schema + +## Summary + +Integriq runs its own flow engine. `visual-flow-orchestration` declares a +`flow` schema with an ordered `steps[]` pipeline, plus `flow_run` and +`flow_run_log`, and `FlowRunnerService` executes it. OpenRegister runs a flow +engine too, with a `nodes`/`edges` graph, triggers, an execution mode and 22 +node types. + +Two engines is the defect. This change retires Integriq's and moves its flows +onto OpenRegister's, so the fleet has one flow model, one runner and one +editor. + +## Motivation + +### The slug collision is the symptom, not the disease + +Measured on the dev instance 2026-08-31: `flow` is declared by both +`openregister` and `integriq`. A schema slug is global on a shared +OpenRegister, so `SchemaMapper::find()` can answer either one, and a bare +`"$ref": "flow"` resolves to whichever row it fetches first. Renaming +Integriq's slug would clear that collision in an afternoon. + +It would also leave the fleet with two flow engines, which is the thing worth +fixing. The two models barely overlap: + +| | openregister `flow` | integriq `flow` | +|---|---|---| +| shape | `nodes` + `edges` graph | ordered `steps[]` | +| start | `trigger` catalog, `cron`, `executionMode` | none | +| step vocabulary | 22 node types | `call`, `mapping`, `synchronization`, `event`, `approval`, `branch` | +| properties | 14 | 7 | + +They share `name` and `description`. + +### The gap that kept them separate is already being closed + +OpenRegister's engine has no node that makes an outbound call and none that +runs a synchronization, which is Integriq's entire purpose. That is exactly +what the existing `integriq-flow-nodes` change addresses: it contributes +`openconnector.source-call` and `openconnector.synchronization-run` through +`RegisterFlowNodesEvent`, the seam OpenRegister already ships for apps to add +node types without patching the engine. + +Once those nodes exist, Integriq's step vocabulary maps onto OpenRegister's +with one gap remaining: + +| integriq step | openregister node | +|---|---| +| `call` | `openconnector.source-call` (integriq-flow-nodes) | +| `synchronization` | `openconnector.synchronization-run` (integriq-flow-nodes) | +| `mapping` | `MapNode` | +| `branch` | `RouterNode` / `SwitchNode` | +| `event` | `TriggerObjectNode` on the read side; the emit side needs confirming | +| `approval` | **no node exists** | + +So this change also contributes an `openconnector.approval-request` node, and +confirms or contributes the emit half of `event`. + +## What changes + +1. **An approval node.** Integriq contributes `openconnector.approval-request` + alongside the two nodes `integriq-flow-nodes` adds, so the `approval` step + has a home. Its pause/resume semantics ride on `AwaitSignalNode`, which + OpenRegister already ships. +2. **A migration** from `steps[]` to `nodes`/`edges`. Step `order` is a stable + identifier that `branch` targets reference by value, so it maps to node + ids directly and the graph is a chain with branch edges. Existing `flow` + objects are rewritten in place by a repair step, and `flow_run` / + `flow_run_log` history is retained read-only rather than migrated: run + history is evidence, and rewriting evidence to a new shape is worse than + keeping it where it is. +3. **`FlowRunnerService` is retired** in favour of OpenRegister's + `FlowRunService`. `FlowsController`'s docblock already records that flows + are read through `/api/objects/integriq/flow/*`, so the read path moves to + the openregister register. +4. **The three schemas are retired** from + `register.d/visual-flow-orchestration.json`, and the live rows are removed + with `occ openregister:schemas:prune-retired --app integriq --slug flow` + (and `flow_run`, `flow_run_log`). The import unions schema ids, so a + descriptor deletion alone leaves the rows behind. +5. **`RuleToFlowGenerator`** emits OpenRegister flow graphs rather than + Integriq step lists. + +## What this is not + +It is not a rename. `integration-flow` was considered and rejected: it clears +the slug collision and leaves both engines standing, which is the more +expensive outcome because every later flow feature then has to be built twice. + +## Risks + +- **The editor.** Integriq ships a step-list editor; OpenRegister ships a + visual flow builder. Users lose one and gain the other. That is a UX change + and needs the PO's sign-off before Task 1, not after. +- **`event` emit is unconfirmed.** The mapping table above marks it as needing + confirmation. If OpenRegister has no emit node, this change grows by one + more contributed node. +- **Run history.** Keeping `flow_run` read-only means the two schemas stay in + the register after the migration, retired but not deleted. That is a + deliberate choice and it means the slug collision on `flow` clears while + `flow_run` / `flow_run_log` remain Integriq-owned. Neither collides today. diff --git a/openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md b/openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md new file mode 100644 index 000000000..f9ecbc98a --- /dev/null +++ b/openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md @@ -0,0 +1,63 @@ +# flow-orchestration Specification + +## Purpose + +Integriq's flows run on OpenRegister's flow engine. Integriq contributes the +node types that engine lacks; it does not run an engine of its own. + +## Requirements + +### Requirement: Integriq MUST NOT declare its own flow schema + +A schema slug is global on a shared OpenRegister, and OpenRegister declares +`flow` as a core schema. An app that declares the same slug makes both +definitions resolvable from one bare `$ref`, and the wrong one can answer. + +Integriq's flows are OpenRegister `flow` objects. Integriq contributes node +types through `RegisterFlowNodesEvent`. + +#### Scenario: The register declares no flow schema +- GIVEN Integriq's shipped register descriptors +- WHEN gate-106 checks them against the fleet slug baseline +- THEN no descriptor declares the slug `flow` + +#### Scenario: The live row is removed, not merely undeclared +- GIVEN an instance that imported Integriq's former `flow` schema +- WHEN `occ openregister:schemas:prune-retired --app integriq --slug flow --apply` runs +- THEN the schema row is deleted and every referencing register is unlinked +- AND a second run reports the slug as not found + +### Requirement: Integriq MUST contribute a node for every step type it retires + +Retiring the step vocabulary without a node for each type leaves a migrated +flow that cannot run. The failure is silent in the way flow failures usually +are: the run reports a step it cannot dispatch, and the work simply does not +happen. + +#### Scenario: Every former step type has a node +- GIVEN the former step vocabulary call, mapping, synchronization, event, approval, branch +- WHEN the flow node registry is enumerated with Integriq enabled +- THEN each type resolves to a registered node + +#### Scenario: An approval step pauses and resumes +- GIVEN a migrated flow whose approval node awaits a signal +- WHEN the request is approved +- THEN the run resumes on the approve edge +- AND when it is rejected the run takes the reject edge + +### Requirement: The migration MUST preserve branch targets + +A `branch` step references its targets by step `order`, which is a stable +identifier rather than an array position. A migration that renumbers loses +every branch. + +#### Scenario: Branch targets survive translation +- GIVEN a flow whose branch step names nextStepOrder 40 and defaultNextStepOrder 50 +- WHEN the flow is translated to nodes and edges +- THEN the branch node's edges point at the nodes whose ids are 40 and 50 + +#### Scenario: A flow with duplicate step orders is refused +- GIVEN a flow with two steps carrying order 20 +- WHEN the translator runs +- THEN it refuses the flow, as FlowRunnerService::run() refuses it today +- AND it does not emit a graph with one of the two nodes missing diff --git a/openspec/changes/retire-integriq-flow-schema/tasks.md b/openspec/changes/retire-integriq-flow-schema/tasks.md new file mode 100644 index 000000000..6a445251b --- /dev/null +++ b/openspec/changes/retire-integriq-flow-schema/tasks.md @@ -0,0 +1,50 @@ +# Tasks: retire-integriq-flow-schema + +Before starting: `integriq-flow-nodes` must be landed, not merely proposed. +This change consumes `openconnector.source-call` and +`openconnector.synchronization-run`; without them the migrated flows have no +node for their two most common steps and every migrated flow is dead on +arrival. + +## Pre-implementation Gate + +- [ ] PO sign-off on the editor change: users lose Integriq's step-list editor and gain OpenRegister's visual flow builder. This is a UX decision, and taking it after the migration is taking it too late. +- [ ] Confirm whether OpenRegister has an event-EMIT node. The `event` step type has no confirmed counterpart; if none exists this change grows by one contributed node. +- [ ] Count the live `flow` objects per instance. A migration whose blast radius is unmeasured is a migration whose rollback is unplanned. + +## Implementation Tasks + +### 1. The missing node + +- [ ] Contribute `openconnector.approval-request` via `RegisterFlowNodesEvent`, implementing `IFlowNode` +- [ ] Pause/resume rides on `AwaitSignalNode`; the node emits the signal name the approval resolves to +- [ ] Unit tests: an approved request resumes the flow, a rejected one takes the reject edge, an expired one fails closed +- [ ] Contribute the event-emit node if the gate above found none + +### 2. The step-to-graph migration + +- [ ] A pure, tested translator: `steps[]` to `nodes`/`edges`. Step `order` becomes the node id, so `branch` targets stay valid without renumbering +- [ ] Property tests: a flow with duplicate `order` values is rejected exactly as `FlowRunnerService::run()` rejects it today, rather than silently producing a graph with a lost node +- [ ] A repair step that rewrites live `flow` objects in place, idempotent, refusing rather than overwriting when an object already carries `nodes` + +### 3. Retire the runner + +- [ ] `FlowsController` reads through the openregister register rather than `integriq` +- [ ] `FlowRunnerService` deleted; its callers moved to OpenRegister's `FlowRunService` +- [ ] `RuleToFlowGenerator` emits `nodes`/`edges`; its `TYPE_FLOW` constant follows +- [ ] `FlowRunnerService::SCHEMA_FLOW`, `SCHEMA_FLOW_RUN` and `SCHEMA_FLOW_RUN_LOG` removed with it + +### 4. Retire the schemas + +- [ ] Remove `flow` from `register.d/visual-flow-orchestration.json` and from the register's schema list +- [ ] Keep `flow_run` and `flow_run_log`, retired but present, so run history stays readable. Record that decision in the fragment's `$comment` +- [ ] Bump `info.version`, or the descriptor change never reaches an existing install +- [ ] `occ openregister:schemas:prune-retired --app integriq --slug flow --apply` on each instance, because the import unions schema ids and never removes one +- [ ] Regenerate `contracts/fleet-schema-slugs.json` in hydra-gates so gate-106's baseline stops recording `flow` as shared + +### 5. Prove it + +- [ ] E2E: a flow that calls a Source, maps the response and writes an object runs green end to end on OpenRegister's engine +- [ ] E2E: an approval step pauses the run and resumes on approve +- [ ] The migration repair step runs twice with the same result +- [ ] `occ openregister:schemas:prune-retired --app integriq --slug flow` reports not-found afterwards, which is the proof the row is gone rather than the descriptor merely edited From cfeaf0f621c5d83eb74b87bccc1efe4c3b43dece Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 12:06:22 +0200 Subject: [PATCH 12/22] chore(lint): let the linter see tests/ and scripts/ (#1797) `npm run lint` was `eslint src`, so two whole trees were never linted. Across the fleet that hid roughly 3,900 errors, none of which any CI leg had shown. Most of it was the config, not the code, and the same two defects were in every app. **scripts/ had no config block at all.** These are CommonJS Node CLI checkers, and flat config defaults every `.js` to ESM with browser-ish globals, so eslint read the CommonJS wrapper itself as undefined identifiers: `require`, `process`, `__dirname`, `__filename`, `module`. A `scripts/**` block now declares the environment. Declaring beats suppressing here: `no-undef` is the rule that catches a genuinely misspelled identifier, and dozens of fake findings would bury a real one. A second block covers `scripts/**/*.mjs`, which is ESM and needs Node's globals without the CommonJS wrapper. **The tests block applied a non-TypeScript-aware rule to TypeScript.** It named `tests/**/*.ts` while setting the CORE `no-unused-vars`, which v9 deliberately turns off for `.ts` in favour of the `@typescript-eslint` version. The core rule reads the parameter names inside a function TYPE as bindings, so t?: (app: string, key: string) => string reports `app` and `key` as unused variables, and every unused `catch (e)` in a `.ts` spec reports twice. The block is split now: `.js`/`.mjs` on the core rule, `.ts`/`.tsx` on the TypeScript one, same patterns on both. Also: stale `eslint-disable` comments naming plugins eslint 10 no longer registers, which are themselves errors ("Definition for rule ... was not found"), and a rule that must not parse shell scripts. The genuinely real findings were the useful part: dead locals, unused imports, dead helper functions, unused `catch` bindings, extensionless relative imports, and a handful of `== null` comparisons spelled out so they still match null AND undefined. Verified per app: `npm run lint` 0 errors over src + tests + scripts, `prettier --check` clean, and the unit suite still green. --- eslint.config.mjs | 154 +++++++++++++++++- package.json | 2 +- scripts/build-l10n-js.js | 3 + scripts/check-integration-parity.js | 36 ++-- scripts/check-l10n.js | 17 +- scripts/check-schema-l10n.js | 3 + scripts/clean-l10n.js | 10 +- scripts/find-unwrapped.js | 23 ++- scripts/l10n-ai.js | 58 +++++-- scripts/lib/l10n.js | 3 + .../configuration-export-import.spec.ts | 4 +- .../consumer-rate-limiting.api.spec.ts | 2 +- .../api-direct/endpoint-runtime.api.spec.ts | 2 +- .../e2e/api-direct/rule-pipeline.api.spec.ts | 2 +- .../synchronization-engine.api.spec.ts | 2 +- tests/e2e/api-direct/user-management.spec.ts | 4 +- tests/e2e/docs-screenshots.spec.ts | 6 +- tests/e2e/global-setup.ts | 15 +- tests/e2e/playwright.config.ts | 3 +- .../e2e/regression/dead-letter-replay.spec.ts | 6 +- .../regression/dead-letters-merged.spec.ts | 4 +- tests/e2e/regression/journeys.spec.ts | 18 +- tests/e2e/regression/manifest-pages.spec.ts | 18 +- .../regression/migration-round-trip.spec.ts | 13 +- .../regression/spa-root-resolution.spec.ts | 8 +- tests/e2e/regression/synced-from-leaf.spec.ts | 12 +- tests/e2e/regression/webhook-signing.spec.ts | 6 +- tests/e2e/spec-coverage/_helpers.ts | 6 +- .../action-authorization.spec.ts | 2 +- .../spec-coverage/api-product-gateway.spec.ts | 4 +- .../cloud-event-management.spec.ts | 6 +- .../configuration-export-import.spec.ts | 4 +- .../configuration-import-export-ui.spec.ts | 4 +- .../spec-coverage/connector-catalog.spec.ts | 2 +- .../spec-coverage/consumer-management.spec.ts | 6 +- tests/e2e/spec-coverage/dashboard.spec.ts | 8 +- .../e2e/spec-coverage/dead-letters-ui.spec.ts | 22 +-- .../demo-data-setup-step.spec.ts | 8 +- .../spec-coverage/endpoint-runtime.spec.ts | 6 +- .../spec-coverage/features-roadmap.spec.ts | 4 +- .../spec-coverage/flow-native-sync.spec.ts | 11 +- .../spec-coverage/flow-orchestration.spec.ts | 8 +- .../e2e/spec-coverage/job-management.spec.ts | 6 +- .../spec-coverage/mapping-and-search.spec.ts | 4 +- .../spec-coverage/nav-and-index-pages.spec.ts | 8 +- .../spec-coverage/prometheus-metrics.spec.ts | 4 +- tests/e2e/spec-coverage/rule-pipeline.spec.ts | 6 +- .../spec-coverage/source-management.spec.ts | 6 +- .../sync-editor-bridge-types.spec.ts | 14 +- .../synchronization-engine.spec.ts | 6 +- .../e2e/spec-coverage/user-management.spec.ts | 4 +- tests/e2e/spec-coverage/webhooks.spec.ts | 4 +- tests/e2e/support/appRoot.ts | 4 +- tests/e2e/visual/_visual-helpers.ts | 6 +- tests/e2e/visual/integriq.visual.spec.ts | 2 +- tests/e2e/workflows/_fixture.ts | 28 ++-- .../e2e/workflows/source-mapping-crud.spec.ts | 17 +- .../synchronization-workflow.spec.ts | 17 +- tests/l10n/check-l10n-parity.js | 6 +- tests/l10n/check-l10n.js | 7 +- tests/validate-json-strict.js | 5 +- tests/validate-manifest.js | 4 +- tests/vitest/actionFormsShared.spec.js | 4 +- .../buildAuthenticationConfiguration.spec.js | 8 +- tests/vitest/catalogStore.spec.js | 2 +- tests/vitest/consumerDraft.spec.js | 6 +- tests/vitest/editorModalSlotContract.spec.js | 3 +- tests/vitest/formsBridge.spec.js | 12 +- tests/vitest/jobDraft.spec.js | 4 +- .../liveObjectSubscriptionCleanup.spec.js | 4 +- tests/vitest/ncButtonSubmitType.spec.js | 4 +- tests/vitest/routerRef.spec.js | 4 +- tests/vitest/ruleDraft.spec.js | 6 +- tests/vitest/runTargets.spec.js | 4 +- tests/vitest/sourceCredentialRef.spec.js | 12 +- tests/vitest/tablesBridge.spec.js | 10 +- 76 files changed, 495 insertions(+), 281 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a4bbb8c76..d6b672728 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -144,11 +144,44 @@ export default [ 'no-console': 'off', 'n/no-process-exit': 'off', 'n/hashbang': 'off', - // `_` / `__` as a deliberate throwaway binding — `catch (_)`, a - // discarded destructuring slot. Narrow on purpose: the pattern matches - // UNDERSCORES ONLY, so a real name that happens to start with `_` is - // still reported. v9 drives plain `.js` through the CORE rule (the - // `@typescript-eslint` swap is per-file-type), so it is set here. + // Tests import devDependencies by definition; this rule is about what + // ships in the published package, which tests/ never does. + 'n/no-unpublished-import': 'off', + // 🔴 COMMENTS ONLY, and the exception is load-bearing. Four + // api-direct specs document the `testIgnore` glob that excludes them + // from the gate-19 UI run, and a block comment cannot contain the + // literal `**` + `/` because that closes it at `*/`. The files carry a + // ZERO-WIDTH SPACE (U+200B) between the two to break the sequence. + // + // Deleting the character to satisfy the rule would terminate the + // comment early and break the file. In CODE an invisible character is + // a genuine hazard — a look-alike inside an identifier or a string — + // and the rule still catches that. In a comment it cannot change + // behaviour. + 'no-irregular-whitespace': ['error', { skipComments: true }], + }, + }, + + { + // `_` / `__` as a deliberate throwaway binding — `catch (_)`, a discarded + // destructuring slot. Narrow on purpose: the pattern matches UNDERSCORES + // ONLY, so a real name that happens to start with `_` is still reported. + // + // 🔴 `.js` / `.mjs` ONLY, NOT `.ts`. The CORE rule is not TypeScript-aware: + // applied to a `.ts` file it reads the parameter NAMES inside a function + // TYPE as bindings and reports them unused. Measured on humaniq — + // + // t?: (app: string, key: string) => string + // + // produced four `no-unused-vars` errors for `app` and `key`, which are + // documentation, not variables. The same mis-scoping made every unused + // `catch (e)` in a `.ts` spec report TWICE, once per rule. + // + // v9 already turns the core rule off for `.ts` and drives + // `@typescript-eslint/no-unused-vars` instead; naming `.ts` here switched + // it back on. TypeScript files are handled by the block below. + files: ['tests/**/*.js', 'tests/**/*.mjs'], + rules: { 'no-unused-vars': [ 'error', { @@ -165,12 +198,117 @@ export default [ ignoreRestSiblings: true, }, ], - // Tests import devDependencies by definition; this rule is about what - // ships in the published package, which tests/ never does. - 'n/no-unpublished-import': 'off', }, }, + { + // The TypeScript half of the block above. Same intent, same patterns, on + // the rule that actually understands the language: it knows a name inside + // a function type is not a binding, so type annotations stay quiet while a + // genuinely dead local is still reported. + files: ['tests/**/*.ts', 'tests/**/*.tsx'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_+$', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_+$', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + }, + }, + + { + // 🔴 Node-side CLI tooling under `scripts/`, which is COMMONJS. Flat + // config defaults every `.js` to ESM with browser-ish globals, so without + // this block eslint reports the CommonJS wrapper itself as undefined + // identifiers. Measured on this app: 52 of the 233 errors under + // `tests/` + `scripts/` were `no-undef`, ALL of them in `scripts/`, and + // all five names were the environment rather than a typo — `process` 23, + // `require` 20, `__dirname` 6, `__filename` 2, `module` 1. + // + // This is describing the environment, not relaxing a rule, and it is the + // same argument the test-globals block below makes: declaring them keeps + // `no-undef` able to do its real job, which is catching a genuinely + // misspelled identifier. Suppressing the rule instead would bury that. + // + // `no-console` is off because printing its report is what a CLI checker + // is FOR. + // + // 🔴 NO `n/*` ENTRIES HERE, DELIBERATELY. `eslint-plugin-n` is NOT + // registered for these files under eslint 10 + @nextcloud/eslint-config + // 9, so `'n/no-process-exit': 'off'` would be dead config that reads as + // if it were doing something. Measured both ways on this app: 0 `n/` + // findings with the entries and 0 without. + // + // What DID report was the opposite — four `scripts/*.js` carried + // `/* eslint-disable n/no-process-exit */` and `/* eslint-disable + // n/shebang */` left over from the eslintrc era, and an inline disable + // naming an unregistered plugin is itself an error ("Definition for rule + // 'n/shebang' was not found"). Those 8 comments are removed; do not add + // `n/*` rules back to replace them. + // + // ⚠️ `.js` and `.cjs` ONLY. A `scripts/*.mjs` is genuinely ESM and must + // keep the default `sourceType`, or `import` stops parsing there. + files: ['scripts/**/*.js', 'scripts/**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'writable', + exports: 'writable', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // The ESM half of the block above. A `scripts/*.mjs` is genuinely a module + // and must keep the default `sourceType`, so it gets Node's globals but + // none of the CommonJS wrapper. Measured: `process` reported undefined 2x + // in hermiq's generate-opengemeenten-icons.mjs and 4x in openregister's + // l10n/runtime-check.mjs, which the `.js`/`.cjs` block deliberately does + // not match. + files: ['scripts/**/*.mjs', 'tests/**/*.mjs'], + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // eslint must not try to PARSE a shell script. `tests/e2e/seed.test.sh` + // matches the `**/*.test.*` glob some presets use, and eslint then reads + // it as JavaScript and reports "Parsing error: Unexpected character" — + // a finding about a file it should never have opened. + ignores: ['**/*.sh', '**/*.bash'], + }, + + // eslint-config-prettier LAST OF THE PRESETS, and it has to be: it only turns // rules OFF, and what it turns off is everything prettier owns — including the // `@stylistic/*` family v9 introduces (`indent`, `quotes`, `semi`). diff --git a/package.json b/package.json index e467d218b..18b82eb40 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build": "NODE_ENV=production webpack --config webpack.config.js --progress", "dev": "NODE_ENV=development webpack --config webpack.config.js --progress", "watch": "NODE_ENV=development webpack --config webpack.config.js --progress --watch", - "lint": "eslint src", + "lint": "eslint src tests scripts", "lint-fix": "npm run lint -- --fix", "test": "vitest run", "test-coverage": "vitest run --coverage", diff --git a/scripts/build-l10n-js.js b/scripts/build-l10n-js.js index 176ef519c..335b08d2c 100644 --- a/scripts/build-l10n-js.js +++ b/scripts/build-l10n-js.js @@ -101,6 +101,9 @@ function renderJs(id, translations, pluralForm) { ].join('\n') } +/** + * + */ function main() { const check = process.argv.includes('--check') const id = appId() diff --git a/scripts/check-integration-parity.js b/scripts/check-integration-parity.js index 0c44a7320..b74516fae 100755 --- a/scripts/check-integration-parity.js +++ b/scripts/check-integration-parity.js @@ -10,11 +10,11 @@ * --------------------------------------- * A leaf has TWO faces (ADR-019 AD-11/AD-13, ADR-066 decisions 4 and 7): * - * * a SERVER face — either a `LeafDescriptor` contributed through + * a SERVER face — either a `LeafDescriptor` contributed through * `RegisterLeafProvidersEvent`, or an `IntegrationProvider` registered on * OpenRegister's `IntegrationRegistry`. This face is what the * `openregister.integrations.leaves` capability advertises. - * * a JS face — a `registerIntegration({ id, … })` call that mounts the + * a JS face — a `registerIntegration({ id, … })` call that mounts the * render pair on `window.OCA.OpenRegister.integrations`. * * The two are correlated ONLY by a shared `id`. Nothing at runtime notices when @@ -132,7 +132,7 @@ function collectFiles(root, test, maxDepth = 10) { let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) - } catch (e) { + } catch { return } for (const ent of entries) { @@ -289,17 +289,17 @@ function resolvePhp(expr, localConsts, globalConsts) { } m = /^(?:self|static)::([A-Z0-9_]+)$/.exec(e) if (m !== null) { - return Object.prototype.hasOwnProperty.call(localConsts, m[1]) + return Object.hasOwn(localConsts, m[1]) ? localConsts[m[1]] : null } m = /^([A-Za-z_][A-Za-z0-9_]*)::([A-Z0-9_]+)$/.exec(e) if (m !== null) { const key = `${m[1]}::${m[2]}` - if (Object.prototype.hasOwnProperty.call(FOREIGN_CONSTANTS, key) === true) { + if (Object.hasOwn(FOREIGN_CONSTANTS, key) === true) { return FOREIGN_CONSTANTS[key] } - return Object.prototype.hasOwnProperty.call(globalConsts, key) + return Object.hasOwn(globalConsts, key) ? globalConsts[key] : null } @@ -351,8 +351,8 @@ function phpLocalConsts(src) { * * Two shapes count as a server face, because both are how a leaf reaches the * `openregister.integrations.leaves` capability: - * * `new LeafDescriptor(…)` — the ADR-066 collect-event contribution; - * * an `IntegrationProvider` (extends `AbstractIntegrationProvider` or + * `new LeafDescriptor(…)` — the ADR-066 collect-event contribution; + * an `IntegrationProvider` (extends `AbstractIntegrationProvider` or * implements `IntegrationProviderInterface`) whose `getId()` returns a * literal — the `IntegrationRegistry::addProvider()` path. * @@ -371,7 +371,7 @@ function collectServerFaces() { let src try { src = fs.readFileSync(file, 'utf8') - } catch (e) { + } catch { continue } sources.set(file, src) @@ -504,7 +504,7 @@ function resolveJs(expr, locals) { } if ( /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e) === true - && Object.prototype.hasOwnProperty.call(locals, e) === true + && Object.hasOwn(locals, e) === true ) { return locals[e] } @@ -588,7 +588,7 @@ function collectJsRegistrations() { let src try { src = fs.readFileSync(file, 'utf8') - } catch (e) { + } catch { continue } // BOTH SUPPORTED REGISTRATION APIs, NOT ONE. @@ -737,7 +737,7 @@ function collectOrSchemas() { let doc try { doc = JSON.parse(fs.readFileSync(file, 'utf8')) - } catch (e) { + } catch { continue } const declared = doc && doc.components && doc.components.schemas @@ -959,7 +959,7 @@ function main() { continue } counts.R6++ - if (Object.prototype.hasOwnProperty.call(schemas, slug) === false) { + if (Object.hasOwn(schemas, slug) === false) { failures.push( `✗ [R6 offlineConfig] leaf offlineConfig.${key} = "${slug}" (${r.file}) names a ` + `schema this repo does not declare in lib/Settings/** — the leaf would query a ` @@ -996,12 +996,12 @@ function main() { .map(([rule, n]) => `${rule}:${n}`) .join(' ') if (failures.length === 0) { - // eslint-disable-next-line no-console + console.log( `✓ integration parity: ${scope} — all rules pass (assertions run per rule: ${perRule})`, ) if (Object.values(counts).every((n) => n === 0) === true) { - // eslint-disable-next-line no-console + console.error( '✗ integration parity: every rule had ZERO subject matter, yet gate-24 selected this ' + 'repo as one that registers leaves. That contradiction means this checker failed to ' @@ -1014,15 +1014,15 @@ function main() { // The header carries no `✗` on purpose: gate-24 counts violations by // grepping `^✗` in this log, so every violation — and only a violation — // starts a line with it. - // eslint-disable-next-line no-console + console.error( `integration parity gate FAILED — ${failures.length} violation(s) over ${scope}:`, ) for (const f of failures) { - // eslint-disable-next-line no-console + console.error(f) } - // eslint-disable-next-line no-console + console.error(`\nAssertions run per rule: ${perRule}`) process.exit(1) } diff --git a/scripts/check-l10n.js b/scripts/check-l10n.js index d87201a86..575561212 100755 --- a/scripts/check-l10n.js +++ b/scripts/check-l10n.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n/i18n consistency checker. * @@ -19,7 +17,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, walk, @@ -40,6 +37,9 @@ const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const RESET = '\x1b[0m' +/** + * + */ function rel(p) { return path.relative(ROOT, p) } @@ -106,6 +106,9 @@ const NON_DISPLAY_ATTRS = new Set([ 'back-route', // Vue Router route name passed to $router.push({ name }) ]) +/** + * + */ function findUnwrapped(vueFiles, keys) { const hits = [] for (const file of vueFiles) { @@ -166,12 +169,18 @@ function findUnwrapped(vueFiles, keys) { return hits } +/** + * + */ function printSection(title, color, body) { console.log(`${color}${BOLD}${title}${RESET}`) console.log(body) console.log('') } +/** + * + */ function main() { const { app, translations } = loadJsTranslations(L10N_FILE) const keys = new Set(Object.keys(translations)) diff --git a/scripts/check-schema-l10n.js b/scripts/check-schema-l10n.js index 8a860b330..3c4b2626f 100644 --- a/scripts/check-schema-l10n.js +++ b/scripts/check-schema-l10n.js @@ -113,6 +113,9 @@ function collect(node, where, sink) { for (const value of Object.values(node)) collect(value, where, sink) } +/** + * + */ function main() { const update = process.argv.includes('--update') const list = process.argv.includes('--list') diff --git a/scripts/clean-l10n.js b/scripts/clean-l10n.js index 226acb9d4..f835da042 100755 --- a/scripts/clean-l10n.js +++ b/scripts/clean-l10n.js @@ -1,8 +1,6 @@ #!/usr/bin/env node -/* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + + /** * l10n unused-key remover. * @@ -29,7 +27,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -54,6 +51,9 @@ const apply = args.has('--apply') // ---------- Main ---------- +/** + * + */ function main() { if (!fs.existsSync(ENGLISH_FILE)) { console.error(`English source file not found: ${ENGLISH_FILE}`) diff --git a/scripts/find-unwrapped.js b/scripts/find-unwrapped.js index 4da6572c5..7a1c663ef 100644 --- a/scripts/find-unwrapped.js +++ b/scripts/find-unwrapped.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * Candidate unwrapped-string detector. * @@ -28,7 +26,6 @@ const fs = require('fs') const path = require('path') - const { walk } = require('./lib/l10n.js') const ROOT = path.resolve(__dirname, '..') @@ -111,6 +108,9 @@ function isComponentAttrOptOut(tagName, attrName) { // ---------- CLI ---------- +/** + * + */ function parseFlags(argv) { const flags = {} const positionals = [] @@ -142,6 +142,9 @@ const minLength = flags['min-length'] ? Math.max(1, parseInt(flags['min-length'] // ---------- helpers ---------- +/** + * + */ function rel(p) { return path.relative(ROOT, p) } @@ -305,6 +308,9 @@ function computeTCallRanges(text, app) { return ranges } +/** + * + */ function isInsideRange(pos, ranges) { for (const [start, end] of ranges) { if (pos >= start && pos < end) return true @@ -447,6 +453,9 @@ function isInsideStoreCall(expr, pos) { return /Store$/.test(baseIdent) } +/** + * + */ function findStringLiteralsInExpression(expr) { const out = [] let i = 0 @@ -716,6 +725,9 @@ function scanScript(file, fullText, scriptStart, scriptEnd, tCallRanges) { // ---------- main ---------- +/** + * + */ function findVueFiles(roots) { const files = [] for (const root of roots) { @@ -756,6 +768,9 @@ function detectAppName() { process.exit(2) } +/** + * + */ function main() { const roots = positionals.length ? positionals.map((p) => path.isAbsolute(p) ? p : path.join(process.cwd(), p)) diff --git a/scripts/l10n-ai.js b/scripts/l10n-ai.js index e1dba92ac..f20ffe098 100644 --- a/scripts/l10n-ai.js +++ b/scripts/l10n-ai.js @@ -1,8 +1,6 @@ #!/usr/bin/env node /* eslint-disable jsdoc/require-param */ -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * AI-focused l10n CRUD tool. Designed to be invoked one subcommand at a time * by Claude (or other automation) so individual operations stay cheap in @@ -31,7 +29,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -115,6 +112,9 @@ function parseValuePairs(pairs) { // ---------- file helpers ---------- +/** + * + */ function loadAll() { const files = listJsLocaleFiles(L10N_DIR) if (!files.length) { @@ -147,17 +147,26 @@ function writeAll(entries) { } } +/** + * + */ function fail(msg, code = 1) { console.error(msg) process.exit(code) } +/** + * + */ function rel(p) { return path.relative(ROOT, p) } // ---------- subcommands ---------- +/** + * + */ function cmdHas(args) { const { positionals, flags } = parseArgs(args) const [key] = positionals @@ -187,6 +196,9 @@ function cmdHas(args) { } } +/** + * + */ function cmdGet(args) { const { positionals } = parseArgs(args) const [key] = positionals @@ -195,7 +207,7 @@ function cmdGet(args) { const entries = loadAll() let any = false for (const e of entries) { - if (Object.prototype.hasOwnProperty.call(e.translations, key)) { + if (Object.hasOwn(e.translations, key)) { any = true const v = e.translations[key] const out = Array.isArray(v) ? JSON.stringify(v) : v @@ -208,6 +220,9 @@ function cmdGet(args) { } } +/** + * + */ function cmdFind(args) { const { positionals } = parseArgs(args) const [substring] = positionals @@ -230,6 +245,9 @@ function cmdFind(args) { } } +/** + * + */ function cmdAdd(args) { const { positionals, opts, flags } = parseArgs(args, { repeatable: new Set(['value']) }) const [key] = positionals @@ -276,7 +294,7 @@ function cmdAdd(args) { const existing = [] for (const e of entries) { if (!targetLocales.has(e.locale)) continue - if (Object.prototype.hasOwnProperty.call(e.translations, key)) { + if (Object.hasOwn(e.translations, key)) { existing.push(e.locale) } } @@ -295,6 +313,9 @@ function cmdAdd(args) { for (const e of toWrite) console.log(`${e.locale}.js\t${valueMap[e.locale]}`) } +/** + * + */ function cmdSet(args) { const { positionals, opts } = parseArgs(args) const [key] = positionals @@ -307,7 +328,7 @@ function cmdSet(args) { if (!target) { fail(`set: locale '${opts.locale}' has no l10n/${opts.locale}.js (known: ${entries.map((e) => e.locale).join(', ')})`) } - if (!Object.prototype.hasOwnProperty.call(target.translations, key)) { + if (!Object.hasOwn(target.translations, key)) { fail(`set: key '${key}' not present in ${opts.locale}.js. Use 'add' first.`) } if (Array.isArray(target.translations[key])) { @@ -319,13 +340,16 @@ function cmdSet(args) { console.log(`${target.locale}.js\t${opts.value}`) } +/** + * + */ function cmdRm(args) { const { positionals, flags } = parseArgs(args) const [key] = positionals if (!key) fail('usage: rm [--force]') const entries = loadAll() - const present = entries.filter((e) => Object.prototype.hasOwnProperty.call(e.translations, key)) + const present = entries.filter((e) => Object.hasOwn(e.translations, key)) if (!present.length) { fail(`rm: key '${key}' not found in any locale .js file`) } @@ -350,6 +374,9 @@ function cmdRm(args) { for (const e of toWrite) console.log(`${e.locale}.js\tremoved`) } +/** + * + */ function cmdRename(args) { const { positionals, flags } = parseArgs(args) const [oldKey, newKey] = positionals @@ -357,18 +384,18 @@ function cmdRename(args) { if (oldKey === newKey) fail('rename: old and new keys are identical') const entries = loadAll() - const present = entries.filter((e) => Object.prototype.hasOwnProperty.call(e.translations, oldKey)) + const present = entries.filter((e) => Object.hasOwn(e.translations, oldKey)) if (!present.length) { fail(`rename: key '${oldKey}' not found in any locale .js file`) } - const collisions = entries.filter((e) => Object.prototype.hasOwnProperty.call(e.translations, newKey)) + const collisions = entries.filter((e) => Object.hasOwn(e.translations, newKey)) if (collisions.length && !flags.force) { fail(`rename: target key '${newKey}' already exists in ${collisions.map((e) => e.locale + '.js').join(', ')}. Pass --force to overwrite.`) } const toWrite = [] for (const e of entries) { - if (!Object.prototype.hasOwnProperty.call(e.translations, oldKey)) continue + if (!Object.hasOwn(e.translations, oldKey)) continue const next = { ...e.translations } next[newKey] = next[oldKey] delete next[oldKey] @@ -378,12 +405,18 @@ function cmdRename(args) { for (const e of toWrite) console.log(`${e.locale}.js\trenamed`) } +/** + * + */ function cmdListLocales() { const files = listJsLocaleFiles(L10N_DIR) if (!files.length) fail('list-locales: no l10n/*.js files found') for (const f of files) console.log(localeNameOf(f)) } +/** + * + */ function cmdHelp() { const text = [ 'Usage: node scripts/l10n-ai.js [args...]', @@ -407,6 +440,9 @@ function cmdHelp() { // ---------- main ---------- +/** + * + */ function main() { const [, , sub, ...rest] = process.argv if (!sub || sub === '--help' || sub === '-h') { diff --git a/scripts/lib/l10n.js b/scripts/lib/l10n.js index efeec37ae..1bad1ca20 100644 --- a/scripts/lib/l10n.js +++ b/scripts/lib/l10n.js @@ -313,6 +313,9 @@ function findKeyReferences(srcDir, app, key) { return hits } +/** + * + */ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/tests/e2e/api-direct/configuration-export-import.spec.ts b/tests/e2e/api-direct/configuration-export-import.spec.ts index f3a009c7e..d2e2fc32c 100644 --- a/tests/e2e/api-direct/configuration-export-import.spec.ts +++ b/tests/e2e/api-direct/configuration-export-import.spec.ts @@ -19,7 +19,9 @@ * - The /import page contains a meaningful form */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' const OR_BASE = '/index.php/apps/openregister/api' diff --git a/tests/e2e/api-direct/consumer-rate-limiting.api.spec.ts b/tests/e2e/api-direct/consumer-rate-limiting.api.spec.ts index b64d43e3a..34f0d6e44 100644 --- a/tests/e2e/api-direct/consumer-rate-limiting.api.spec.ts +++ b/tests/e2e/api-direct/consumer-rate-limiting.api.spec.ts @@ -23,7 +23,7 @@ * prerequisite is why the live assertion is deferred rather than run here. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const API_BASE = '/index.php/apps/integriq/api' diff --git a/tests/e2e/api-direct/endpoint-runtime.api.spec.ts b/tests/e2e/api-direct/endpoint-runtime.api.spec.ts index c8d7d0f41..585018790 100644 --- a/tests/e2e/api-direct/endpoint-runtime.api.spec.ts +++ b/tests/e2e/api-direct/endpoint-runtime.api.spec.ts @@ -21,7 +21,7 @@ * fixed: synchronizations now resolve through OpenRegister. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const API_BASE = '/index.php/apps/integriq/api' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' diff --git a/tests/e2e/api-direct/rule-pipeline.api.spec.ts b/tests/e2e/api-direct/rule-pipeline.api.spec.ts index 514840163..819dcd6f6 100644 --- a/tests/e2e/api-direct/rule-pipeline.api.spec.ts +++ b/tests/e2e/api-direct/rule-pipeline.api.spec.ts @@ -17,7 +17,7 @@ * OpenRegister. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' const API_BASE = '/index.php/apps/integriq/api' diff --git a/tests/e2e/api-direct/synchronization-engine.api.spec.ts b/tests/e2e/api-direct/synchronization-engine.api.spec.ts index c776e1721..23c196cd1 100644 --- a/tests/e2e/api-direct/synchronization-engine.api.spec.ts +++ b/tests/e2e/api-direct/synchronization-engine.api.spec.ts @@ -18,7 +18,7 @@ * schema `synchronization`). */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' const API_BASE = '/index.php/apps/integriq/api' diff --git a/tests/e2e/api-direct/user-management.spec.ts b/tests/e2e/api-direct/user-management.spec.ts index 4ba0ee765..0ecd657e5 100644 --- a/tests/e2e/api-direct/user-management.spec.ts +++ b/tests/e2e/api-direct/user-management.spec.ts @@ -12,9 +12,9 @@ * storageState. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as http from 'http' -import { BASE_URL, baseUrlParts } from '../support/baseUrl' +import { BASE_URL, baseUrlParts } from '../support/baseUrl.ts' const BASE = BASE_URL const ME_URL = '/index.php/apps/integriq/api/user/me' diff --git a/tests/e2e/docs-screenshots.spec.ts b/tests/e2e/docs-screenshots.spec.ts index 3cc316cbd..663e1eca6 100644 --- a/tests/e2e/docs-screenshots.spec.ts +++ b/tests/e2e/docs-screenshots.spec.ts @@ -50,10 +50,12 @@ * Pattern reference: ADR-030 (hydra/openspec/architecture/). */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + import { dismissFirstVisitOverlays } from '@conduction/nextcloud-vue/testing/playwright' -import * as path from 'path' +import { expect, test } from '@playwright/test' import * as fs from 'fs' +import * as path from 'path' const SHOT_ROOT = path.resolve( __dirname, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index fdd48f6aa..68c916e24 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -19,12 +19,14 @@ * from decidesk's journeydoc setup. */ -import { chromium, request, type FullConfig } from '@playwright/test' +import type { FullConfig } from '@playwright/test' + +import { seedFirstVisitOverlaysSeen } from '@conduction/nextcloud-vue/testing/playwright' +import { chromium, request } from '@playwright/test' import { execSync } from 'child_process' -import * as path from 'path' import * as fs from 'fs' -import { BASE_URL } from './support/baseUrl' -import { seedFirstVisitOverlaysSeen } from '@conduction/nextcloud-vue/testing/playwright' +import * as path from 'path' +import { BASE_URL } from './support/baseUrl.ts' const AUTH_DIR = path.resolve(__dirname, '.auth') const STORAGE_STATE = path.join(AUTH_DIR, 'admin.json') @@ -48,7 +50,7 @@ function ensureBundleBuilt(): void { if (fs.existsSync(BUNDLE_PATH)) { return } - // eslint-disable-next-line no-console + console.log( `[playwright globalSetup] bundle missing at ${BUNDLE_PATH}; running 'npm run build' once…`, ) @@ -192,12 +194,11 @@ export default async function globalSetup(config: FullConfig): Promise { headers: { requesttoken: token }, }) return res.status - } catch (e) { + } catch { return -1 } }) if (wizardStatus !== 200 && wizardStatus !== 404) { - // eslint-disable-next-line no-console console.warn( `[playwright globalSetup] first-run wizard dismissal returned ${wizardStatus}; ` + 'specs may hit an overlay that blocks clicks without hiding anything.', diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 2f29b96ad..f547658f4 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -80,8 +80,7 @@ import { defineConfig, devices } from '@playwright/test' import * as path from 'path' - -import { BASE_URL } from './support/baseUrl' +import { BASE_URL } from './support/baseUrl.ts' const APP_ROOT = path.resolve(__dirname, '..', '..') diff --git a/tests/e2e/regression/dead-letter-replay.spec.ts b/tests/e2e/regression/dead-letter-replay.spec.ts index 693e3d2cb..1b520caa6 100644 --- a/tests/e2e/regression/dead-letter-replay.spec.ts +++ b/tests/e2e/regression/dead-letter-replay.spec.ts @@ -54,8 +54,10 @@ * - lib/Service/EventService.php */ -import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' -import { gotoAppRoute, expectRouteMatched } from '../support/appRoot' +import type { ConsoleMessage, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { expectRouteMatched, gotoAppRoute } from '../support/appRoot.ts' /** * The route that actually mounts `EventDeliveriesPage` (ADR-080 merge). diff --git a/tests/e2e/regression/dead-letters-merged.spec.ts b/tests/e2e/regression/dead-letters-merged.spec.ts index 005aff311..2459a094c 100644 --- a/tests/e2e/regression/dead-letters-merged.spec.ts +++ b/tests/e2e/regression/dead-letters-merged.spec.ts @@ -30,8 +30,8 @@ * - src/views/Synchronization/SyncDeadLetterPage.vue */ -import { test, expect } from '@playwright/test' -import { gotoAppRoute, expectRouteMatched } from '../support/appRoot' +import { expect, test } from '@playwright/test' +import { expectRouteMatched, gotoAppRoute } from '../support/appRoot.ts' /** * The merged operations surface. diff --git a/tests/e2e/regression/journeys.spec.ts b/tests/e2e/regression/journeys.spec.ts index f70ae29b1..b37b981c2 100644 --- a/tests/e2e/regression/journeys.spec.ts +++ b/tests/e2e/regression/journeys.spec.ts @@ -32,14 +32,12 @@ * OR backend, so the suite is end-to-end UI-driven. */ -import { test, expect, Page } from '@playwright/test' -import { BASE_URL } from '../support/baseUrl' -import { appDialog } from '../support/dialogs' -import { resolveAppRoot, expectRouteMatched } from '../support/appRoot' +import type { Locator } from '@playwright/test' +import type { Page } from '@playwright/test' -const NEXTCLOUD = BASE_URL -const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' -const ADMIN_PASS = process.env.NC_ADMIN_PASS || 'admin' +import { expect, test } from '@playwright/test' +import { expectRouteMatched, resolveAppRoot } from '../support/appRoot.ts' +import { appDialog } from '../support/dialogs.ts' const OR = '/index.php/apps/openregister/api/objects/integriq' @@ -504,7 +502,7 @@ async function deleteViaUi( // still uses checkboxes. const row = page.getByRole('row', { name: new RegExp(name) }).first() const rowVisible = await row.isVisible().catch(() => false) - let rowCheckbox: import('@playwright/test').Locator + let rowCheckbox: Locator if (rowVisible) { rowCheckbox = row.getByRole('checkbox').first() } else { @@ -617,7 +615,7 @@ async function editViaUi( // CnRowActions/CnCardItem renders an overflow-actions NcActions button. const row = page.getByRole('row', { name: new RegExp(name) }).first() const rowVisible = await row.isVisible().catch(() => false) - let actionsBtn: import('@playwright/test').Locator + let actionsBtn: Locator if (rowVisible) { actionsBtn = row.getByRole('button', { name: /Actions/i }).first() } else { @@ -755,7 +753,7 @@ async function singleDeleteViaUi(page: Page, schemaSlug: string, name: string) { // Find and click the Actions button near the item name. const row = page.getByRole('row', { name: new RegExp(name) }).first() const rowVisible = await row.isVisible().catch(() => false) - let actionsBtn: import('@playwright/test').Locator + let actionsBtn: Locator if (rowVisible) { actionsBtn = row.getByRole('button', { name: /Actions/i }).first() } else { diff --git a/tests/e2e/regression/manifest-pages.spec.ts b/tests/e2e/regression/manifest-pages.spec.ts index 86cb7490b..0a3a42a91 100644 --- a/tests/e2e/regression/manifest-pages.spec.ts +++ b/tests/e2e/regression/manifest-pages.spec.ts @@ -43,8 +43,10 @@ * - src/manifest.json */ -import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' +import type { Page } from '@playwright/test' +import type { ConsoleMessage, Page } from '@playwright/test' +import { expect, test } from '@playwright/test' /* * SCENARIOS THIS FILE PROVES. * @@ -103,7 +105,6 @@ import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' * — the manifest type and the mount are proven; that widget counts resolve * via dataSource blocks against OR's aggregate endpoint is not. */ - // In Nextcloud installs with `htaccess.RewriteBase => '/'` (the // default for the apache-served dev container) `generateUrl` returns // `/apps/integriq` and the Vue Router's `base` is set to that — @@ -124,9 +125,9 @@ import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' // Resolution now comes from `OC.generateUrl` — the function src/main.js itself // calls to build the router base — and each test asserts the router MATCHED // before looking at anything. -import { resolveAppRoot, expectRouteMatched } from '../support/appRoot' +import { expectRouteMatched, resolveAppRoot } from '../support/appRoot.ts' -async function rootUrl(page: import('@playwright/test').Page): Promise { +async function rootUrl(page: Page): Promise { return await resolveAppRoot(page) } @@ -364,11 +365,16 @@ test.describe('manifest pages — schema-driven render', () => { }) test.describe('manifest schema validation', () => { + // This suite compiles as CommonJS, so `import.meta` is a syntax error and + // `require` is how it reaches the filesystem. The directives below say so + // at each site; the reason is here. function readManifest(): Record { + // eslint-disable-next-line @typescript-eslint/no-require-imports const manifestPath = require('path').resolve( __dirname, '../../../src/manifest.json', ) + // eslint-disable-next-line @typescript-eslint/no-require-imports return JSON.parse(require('fs').readFileSync(manifestPath, 'utf-8')) } @@ -378,19 +384,23 @@ test.describe('manifest schema validation', () => { // fails it as an error with no statement of intent — and a reader // checking whether "the manifest exists and parses" is covered cannot // see an assertion that isn't written down. + // eslint-disable-next-line @typescript-eslint/no-require-imports const manifestPath = require('path').resolve( __dirname, '../../../src/manifest.json', ) expect( + // eslint-disable-next-line @typescript-eslint/no-require-imports require('fs').existsSync(manifestPath), `manifest.json must exist at ${manifestPath}`, ).toBe(true) expect( + // eslint-disable-next-line @typescript-eslint/no-require-imports require('fs').statSync(manifestPath).isFile(), 'manifest.json must be a regular file', ).toBe(true) expect( + // eslint-disable-next-line @typescript-eslint/no-require-imports () => JSON.parse(require('fs').readFileSync(manifestPath, 'utf-8')), 'manifest.json must parse as valid JSON with no syntax errors', ).not.toThrow() diff --git a/tests/e2e/regression/migration-round-trip.spec.ts b/tests/e2e/regression/migration-round-trip.spec.ts index 69fcd51eb..ebe86c97f 100644 --- a/tests/e2e/regression/migration-round-trip.spec.ts +++ b/tests/e2e/regression/migration-round-trip.spec.ts @@ -32,13 +32,10 @@ * synced-from-leaf.spec.ts) rather than producing a false failure. */ -import { - test, - expect, - request as pwRequest, - APIRequestContext, -} from '@playwright/test' -import { BASE_URL } from '../support/baseUrl' +import type { APIRequestContext } from '@playwright/test' + +import { expect, request as pwRequest, test } from '@playwright/test' +import { BASE_URL } from '../support/baseUrl.ts' const NEXTCLOUD = BASE_URL const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' @@ -110,7 +107,6 @@ async function readStorageMigrated(ctx: APIRequestContext): Promise { .catch(() => null) if (res === null) { - // eslint-disable-next-line no-console console.warn('[migration-round-trip] storage_migrated probe: request threw') return false } @@ -119,7 +115,6 @@ async function readStorageMigrated(ctx: APIRequestContext): Promise { const ocsStatus = body?.ocs?.meta?.statuscode const value = body?.ocs?.data?.data - // eslint-disable-next-line no-console console.info( `[migration-round-trip] storage_migrated probe: HTTP ${res.status()},` + ` ocs.meta.statuscode=${String(ocsStatus)}, value=${JSON.stringify(value)}`, diff --git a/tests/e2e/regression/spa-root-resolution.spec.ts b/tests/e2e/regression/spa-root-resolution.spec.ts index 89f504237..70f48f80a 100644 --- a/tests/e2e/regression/spa-root-resolution.spec.ts +++ b/tests/e2e/regression/spa-root-resolution.spec.ts @@ -28,8 +28,12 @@ * would be worthless in exactly the environment it was written for. */ -import { test, expect } from '@playwright/test' -import { resolveAppRoot, gotoAppRoute, expectRouteMatched } from '../support/appRoot' +import { expect, test } from '@playwright/test' +import { + expectRouteMatched, + gotoAppRoute, + resolveAppRoot, +} from '../support/appRoot.ts' /** A route that exists in `src/manifest.json` and needs no fixture data. */ const ROUTE = '/sources' diff --git a/tests/e2e/regression/synced-from-leaf.spec.ts b/tests/e2e/regression/synced-from-leaf.spec.ts index 46ba4f977..eea932ef8 100644 --- a/tests/e2e/regression/synced-from-leaf.spec.ts +++ b/tests/e2e/regression/synced-from-leaf.spec.ts @@ -29,13 +29,10 @@ * suite skips (not fails) when that flag isn't set on the instance. */ -import { - test, - expect, - request as pwRequest, - type APIRequestContext, -} from '@playwright/test' -import { BASE_URL } from '../support/baseUrl' +import type { APIRequestContext } from '@playwright/test' + +import { expect, request as pwRequest, test } from '@playwright/test' +import { BASE_URL } from '../support/baseUrl.ts' const NEXTCLOUD = BASE_URL const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' @@ -178,7 +175,6 @@ test.describe('Synced-from leaf — contract provenance on objects', () => { } if (!storageMigrated) { - // eslint-disable-next-line no-console console.warn(`[synced-from-leaf] skipping: ${disabledReason}`) skipReason = disabledReason return diff --git a/tests/e2e/regression/webhook-signing.spec.ts b/tests/e2e/regression/webhook-signing.spec.ts index f4e87fba1..c3d2baded 100644 --- a/tests/e2e/regression/webhook-signing.spec.ts +++ b/tests/e2e/regression/webhook-signing.spec.ts @@ -57,8 +57,10 @@ * - lib/Controller/EventsController.php */ -import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' -import { gotoAppRoute, expectRouteMatched } from '../support/appRoot' +import type { ConsoleMessage, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { expectRouteMatched, gotoAppRoute } from '../support/appRoot.ts' // The candidate-probe that used to live here always returned the FIRST prefix, // because Nextcloud serves the identical SPA shell under both — so on CI these diff --git a/tests/e2e/spec-coverage/_helpers.ts b/tests/e2e/spec-coverage/_helpers.ts index 14592a36f..c3202fe4c 100644 --- a/tests/e2e/spec-coverage/_helpers.ts +++ b/tests/e2e/spec-coverage/_helpers.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -11,8 +13,8 @@ * so console-error / 500 assertions only fail on integriq-origin * problems. */ -import { type Page, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // The one integriq URL base for the whole spec-coverage suite. Two // separate things are encoded here, and both were learned from a failing run. diff --git a/tests/e2e/spec-coverage/action-authorization.spec.ts b/tests/e2e/spec-coverage/action-authorization.spec.ts index 79df4cf45..fa155f987 100644 --- a/tests/e2e/spec-coverage/action-authorization.spec.ts +++ b/tests/e2e/spec-coverage/action-authorization.spec.ts @@ -12,7 +12,7 @@ * with their groups, and is served by the routes it claims. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' const MATRIX_URL = '/index.php/apps/integriq/api/admin/action-matrix' const ADMIN_SETTINGS_URL = '/index.php/settings/admin/integriq' diff --git a/tests/e2e/spec-coverage/api-product-gateway.spec.ts b/tests/e2e/spec-coverage/api-product-gateway.spec.ts index ba9fadfe6..823d70d15 100644 --- a/tests/e2e/spec-coverage/api-product-gateway.spec.ts +++ b/tests/e2e/spec-coverage/api-product-gateway.spec.ts @@ -24,7 +24,9 @@ * about, for the opposite reason. */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' const APP_BASE = '/index.php/apps/integriq' diff --git a/tests/e2e/spec-coverage/cloud-event-management.spec.ts b/tests/e2e/spec-coverage/cloud-event-management.spec.ts index 67c15325a..ad46d293d 100644 --- a/tests/e2e/spec-coverage/cloud-event-management.spec.ts +++ b/tests/e2e/spec-coverage/cloud-event-management.spec.ts @@ -17,14 +17,14 @@ * the main content area rather than relying on table cell content. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' diff --git a/tests/e2e/spec-coverage/configuration-export-import.spec.ts b/tests/e2e/spec-coverage/configuration-export-import.spec.ts index ffd820c0c..28ab6ab85 100644 --- a/tests/e2e/spec-coverage/configuration-export-import.spec.ts +++ b/tests/e2e/spec-coverage/configuration-export-import.spec.ts @@ -19,7 +19,9 @@ * - The /import page contains a meaningful form */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' const OR_BASE = '/index.php/apps/openregister/api' diff --git a/tests/e2e/spec-coverage/configuration-import-export-ui.spec.ts b/tests/e2e/spec-coverage/configuration-import-export-ui.spec.ts index 536c72d3f..99d366502 100644 --- a/tests/e2e/spec-coverage/configuration-import-export-ui.spec.ts +++ b/tests/e2e/spec-coverage/configuration-import-export-ui.spec.ts @@ -17,7 +17,9 @@ * what the stand-down reason got right and what nobody had checked. */ -import { test, expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' const APP_BASE = '/index.php/apps/integriq' diff --git a/tests/e2e/spec-coverage/connector-catalog.spec.ts b/tests/e2e/spec-coverage/connector-catalog.spec.ts index 92406890e..cdb630bf6 100644 --- a/tests/e2e/spec-coverage/connector-catalog.spec.ts +++ b/tests/e2e/spec-coverage/connector-catalog.spec.ts @@ -20,7 +20,7 @@ * against a provisioned instance. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' diff --git a/tests/e2e/spec-coverage/consumer-management.spec.ts b/tests/e2e/spec-coverage/consumer-management.spec.ts index ffdd61937..e3f89bdd4 100644 --- a/tests/e2e/spec-coverage/consumer-management.spec.ts +++ b/tests/e2e/spec-coverage/consumer-management.spec.ts @@ -20,14 +20,14 @@ * the main content area rather than relying on table cell content. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' diff --git a/tests/e2e/spec-coverage/dashboard.spec.ts b/tests/e2e/spec-coverage/dashboard.spec.ts index addfaa0b5..3ddd16449 100644 --- a/tests/e2e/spec-coverage/dashboard.spec.ts +++ b/tests/e2e/spec-coverage/dashboard.spec.ts @@ -13,12 +13,12 @@ */ import { test } from '@playwright/test' import { - navTo, - trackErrors, + APP_BASE, assertNoAppErrors, expectHeading, - APP_BASE, -} from './_helpers' + navTo, + trackErrors, +} from './_helpers.ts' test.describe('Dashboard — index surface', () => { // @e2e openconnector-comprehensive-tests::dashboard-page-mounts diff --git a/tests/e2e/spec-coverage/dead-letters-ui.spec.ts b/tests/e2e/spec-coverage/dead-letters-ui.spec.ts index c94728565..03bea9257 100644 --- a/tests/e2e/spec-coverage/dead-letters-ui.spec.ts +++ b/tests/e2e/spec-coverage/dead-letters-ui.spec.ts @@ -1,3 +1,7 @@ +import type { Browser } from '@playwright/test' +import type { Page } from '@playwright/test' +import type { ApiClient } from '../workflows/_fixture.ts' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -26,14 +30,9 @@ * state) does NOT assume it got one from declaration order — it asks the * endpoint that feeds the view and skips if the queue is dirty. */ -import { test, expect, type Page } from '@playwright/test' -import { APP_BASE, trackErrors, assertNoAppErrors } from './_helpers' -import { - makeApiClient, - createObject, - deleteObject, - type ApiClient, -} from '../workflows/_fixture' +import { expect, test } from '@playwright/test' +import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture.ts' +import { APP_BASE, assertNoAppErrors, trackErrors } from './_helpers.ts' /** Unique per-run marker so every assertion can scope to this run's rows. */ const runId = `dlui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` @@ -69,15 +68,12 @@ class Fixtures { api!: ApiClient private created: Array<{ schema: string; id: string }> = [] - async open( - browser: import('@playwright/test').Browser, - baseURL: string, - ): Promise { + async open(browser: Browser, baseURL: string): Promise { this.api = await makeApiClient(browser, baseURL) } /** Create an object and register it for teardown. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + async make(schema: string, data: Record): Promise { const obj = await createObject(this.api, schema, data) const id = obj.id ?? obj.uuid diff --git a/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts index d3ef1ed4c..84114ffc0 100644 --- a/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts +++ b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -34,7 +36,7 @@ * * @spec exclude ADR-042/ADR-111 setup contract; no per-app behavioural spec. */ -import { test, expect, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as path from 'path' const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') @@ -53,12 +55,12 @@ async function api( method, headers: { 'Content-Type': 'application/json', - // eslint-disable-next-line no-undef + requesttoken: (window as any).OC?.requestToken || '', 'OCS-APIREQUEST': 'true', }, }) - let json: any = null + let json: any try { json = await res.json() } catch { diff --git a/tests/e2e/spec-coverage/endpoint-runtime.spec.ts b/tests/e2e/spec-coverage/endpoint-runtime.spec.ts index 5063c1315..eeed58389 100644 --- a/tests/e2e/spec-coverage/endpoint-runtime.spec.ts +++ b/tests/e2e/spec-coverage/endpoint-runtime.spec.ts @@ -15,14 +15,14 @@ * the deep-link path). Always use the /apps/ prefix. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' // --------------------------------------------------------------------------- // REQ-EP-UI-001: Endpoint Management UI diff --git a/tests/e2e/spec-coverage/features-roadmap.spec.ts b/tests/e2e/spec-coverage/features-roadmap.spec.ts index 6413597db..b3f7271d4 100644 --- a/tests/e2e/spec-coverage/features-roadmap.spec.ts +++ b/tests/e2e/spec-coverage/features-roadmap.spec.ts @@ -6,8 +6,8 @@ * page (manifest type "roadmap"). Reached from the footer nav entry; shows * a "Features" surface with "Show roadmap" / "Suggest a feature" actions. */ -import { test, expect } from '@playwright/test' -import { navTo, trackErrors, assertNoAppErrors, APP_BASE } from './_helpers' +import { expect, test } from '@playwright/test' +import { APP_BASE, assertNoAppErrors, navTo, trackErrors } from './_helpers.ts' test.describe('Features & roadmap — index surface', () => { // @e2e openconnector-comprehensive-tests::features-roadmap-page-mounts diff --git a/tests/e2e/spec-coverage/flow-native-sync.spec.ts b/tests/e2e/spec-coverage/flow-native-sync.spec.ts index 722245e06..bf81fea33 100644 --- a/tests/e2e/spec-coverage/flow-native-sync.spec.ts +++ b/tests/e2e/spec-coverage/flow-native-sync.spec.ts @@ -68,14 +68,14 @@ * file genuinely covers rather than inventing anchors that resolve to nothing. */ import type { Browser, Page } from '@playwright/test' -import type { ApiClient } from '../workflows/_fixture' +import type { ApiClient } from '../workflows/_fixture.ts' +import { expect, test } from '@playwright/test' import { execFileSync } from 'child_process' import * as fs from 'fs' import * as path from 'path' -import { expect, test } from '@playwright/test' -import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture' -import { expectRouteMatched, resolveAppRoot } from '../support/appRoot' +import { expectRouteMatched, resolveAppRoot } from '../support/appRoot.ts' +import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture.ts' /** OpenRegister's API root — registers, schemas, objects, flows and preflight. */ const OR = '/index.php/apps/openregister/api' @@ -223,7 +223,7 @@ function occ(): OccRunner { // 3. A running container — the dev-container layout. const container = process.env.NC_CONTAINER ?? 'nextcloud' - let running = '' + let running try { running = execFileSync( 'docker', @@ -1189,7 +1189,6 @@ test.describe('The decomposed synchronization — generated, run, re-run', () => * 4. Resumability after a mid-run suspension * ---------------------------------------------------------------- */ - // eslint-disable-next-line playwright/no-skipped-test test.skip('a run suspended mid-page resumes at the page cursor rather than refetching', async () => { // NOT WRITTEN, and named rather than silently dropped. // diff --git a/tests/e2e/spec-coverage/flow-orchestration.spec.ts b/tests/e2e/spec-coverage/flow-orchestration.spec.ts index 63ff3d733..6ff129681 100644 --- a/tests/e2e/spec-coverage/flow-orchestration.spec.ts +++ b/tests/e2e/spec-coverage/flow-orchestration.spec.ts @@ -42,12 +42,12 @@ * unchanged below. */ import type { Browser, Page } from '@playwright/test' -import type { ApiClient } from '../workflows/_fixture' +import type { ApiClient } from '../workflows/_fixture.ts' import { expect, test } from '@playwright/test' -import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture' -import { APP_BASE } from './_helpers' -import { resolveAppRoot, expectRouteMatched } from '../support/appRoot' +import { expectRouteMatched, resolveAppRoot } from '../support/appRoot.ts' +import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture.ts' +import { APP_BASE } from './_helpers.ts' /** OpenRegister's native flow store — a different backend than OR_BASE/OC_API in _fixture.ts, which target openconnector's legacy `flow` schema. */ const FLOWS_API = '/index.php/apps/openregister/api/flows' diff --git a/tests/e2e/spec-coverage/job-management.spec.ts b/tests/e2e/spec-coverage/job-management.spec.ts index 6c134d37c..b9098525c 100644 --- a/tests/e2e/spec-coverage/job-management.spec.ts +++ b/tests/e2e/spec-coverage/job-management.spec.ts @@ -17,14 +17,14 @@ * the main content area rather than relying on table cell content. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' diff --git a/tests/e2e/spec-coverage/mapping-and-search.spec.ts b/tests/e2e/spec-coverage/mapping-and-search.spec.ts index 032d0721c..63c6e98d9 100644 --- a/tests/e2e/spec-coverage/mapping-and-search.spec.ts +++ b/tests/e2e/spec-coverage/mapping-and-search.spec.ts @@ -18,13 +18,13 @@ * navigate directly to a detail URL rather than clicking a table row. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE, openAndDismissCreateModal } from './_helpers' +import { APP_BASE, openAndDismissCreateModal } from './_helpers.ts' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' const API_BASE = '/index.php/apps/integriq/api' diff --git a/tests/e2e/spec-coverage/nav-and-index-pages.spec.ts b/tests/e2e/spec-coverage/nav-and-index-pages.spec.ts index 7f53b28a2..d6dd08af4 100644 --- a/tests/e2e/spec-coverage/nav-and-index-pages.spec.ts +++ b/tests/e2e/spec-coverage/nav-and-index-pages.spec.ts @@ -12,13 +12,13 @@ * and guards against the post-OR-cutover SynchronizationMapper dispatch * regression on the Synchronizations / Endpoints / Cloud events pages. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import { - navTo, - trackErrors, assertNoAppErrors, + navTo, openAndDismissCreateModal, -} from './_helpers' + trackErrors, +} from './_helpers.ts' interface IndexPage { navLabel: string diff --git a/tests/e2e/spec-coverage/prometheus-metrics.spec.ts b/tests/e2e/spec-coverage/prometheus-metrics.spec.ts index 3eb078504..21e7aaa02 100644 --- a/tests/e2e/spec-coverage/prometheus-metrics.spec.ts +++ b/tests/e2e/spec-coverage/prometheus-metrics.spec.ts @@ -13,9 +13,9 @@ * numeric values (those change with data in the DB). */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as http from 'http' -import { absoluteUrl } from '../support/baseUrl' +import { absoluteUrl } from '../support/baseUrl.ts' const METRICS_URL = '/index.php/apps/integriq/api/metrics' diff --git a/tests/e2e/spec-coverage/rule-pipeline.spec.ts b/tests/e2e/spec-coverage/rule-pipeline.spec.ts index c006508eb..662805187 100644 --- a/tests/e2e/spec-coverage/rule-pipeline.spec.ts +++ b/tests/e2e/spec-coverage/rule-pipeline.spec.ts @@ -14,14 +14,14 @@ * the deep-link path). Always use the /apps/ prefix. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' // --------------------------------------------------------------------------- // REQ-RULE-UI-001: Rule Management UI diff --git a/tests/e2e/spec-coverage/source-management.spec.ts b/tests/e2e/spec-coverage/source-management.spec.ts index 503b725f5..c608670f1 100644 --- a/tests/e2e/spec-coverage/source-management.spec.ts +++ b/tests/e2e/spec-coverage/source-management.spec.ts @@ -18,14 +18,14 @@ * Use Cards view where the Add Item button is always visible. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' diff --git a/tests/e2e/spec-coverage/sync-editor-bridge-types.spec.ts b/tests/e2e/spec-coverage/sync-editor-bridge-types.spec.ts index 82f4fc3a3..89b86f338 100644 --- a/tests/e2e/spec-coverage/sync-editor-bridge-types.spec.ts +++ b/tests/e2e/spec-coverage/sync-editor-bridge-types.spec.ts @@ -1,3 +1,6 @@ +import type { Page } from '@playwright/test' +import type { ApiClient } from '../workflows/_fixture.ts' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -41,14 +44,9 @@ * must offer it and the target selector must not — in the same DOM, at the * same moment. */ -import { test, expect, type Page } from '@playwright/test' -import { APP_BASE, trackErrors, assertNoAppErrors } from './_helpers' -import { - makeApiClient, - createObject, - deleteObject, - type ApiClient, -} from '../workflows/_fixture' +import { expect, test } from '@playwright/test' +import { createObject, deleteObject, makeApiClient } from '../workflows/_fixture.ts' +import { APP_BASE, assertNoAppErrors, trackErrors } from './_helpers.ts' const runId = `sedt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` diff --git a/tests/e2e/spec-coverage/synchronization-engine.spec.ts b/tests/e2e/spec-coverage/synchronization-engine.spec.ts index 3d9cf5a52..8699f2357 100644 --- a/tests/e2e/spec-coverage/synchronization-engine.spec.ts +++ b/tests/e2e/spec-coverage/synchronization-engine.spec.ts @@ -17,14 +17,14 @@ * relying on table cell content. */ -import { test, expect } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' // APP_BASE comes from _helpers.ts, the one place that knows both that the // router is hash-mode and that the URL needs the `/index.php/` prefix (without // it, PHP's built-in server on CI 404s the app directory and every assertion // below runs against a 404 page). This file used to keep a private copy of // that string that was missing the prefix. -import { APP_BASE } from './_helpers' +import { APP_BASE } from './_helpers.ts' // --------------------------------------------------------------------------- // REQ-UI-001: Synchronization Management UI diff --git a/tests/e2e/spec-coverage/user-management.spec.ts b/tests/e2e/spec-coverage/user-management.spec.ts index 0bd713c2e..8e4a77823 100644 --- a/tests/e2e/spec-coverage/user-management.spec.ts +++ b/tests/e2e/spec-coverage/user-management.spec.ts @@ -12,9 +12,9 @@ * storageState. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import * as http from 'http' -import { BASE_URL, baseUrlParts } from '../support/baseUrl' +import { BASE_URL, baseUrlParts } from '../support/baseUrl.ts' const BASE = BASE_URL const ME_URL = '/index.php/apps/integriq/api/user/me' diff --git a/tests/e2e/spec-coverage/webhooks.spec.ts b/tests/e2e/spec-coverage/webhooks.spec.ts index 906f93068..d69e266db 100644 --- a/tests/e2e/spec-coverage/webhooks.spec.ts +++ b/tests/e2e/spec-coverage/webhooks.spec.ts @@ -15,8 +15,8 @@ * "Add EventSubscription"). The Webhooks index now lists webhook subscriptions — * a distinct surface from Consumers — and its create button reads "Add Webhook". */ -import { test, expect } from '@playwright/test' -import { navTo, trackErrors, assertNoAppErrors } from './_helpers' +import { expect, test } from '@playwright/test' +import { assertNoAppErrors, navTo, trackErrors } from './_helpers.ts' test.describe('Webhooks — index surface', () => { // @e2e openconnector-comprehensive-tests::webhooks-page-mounts diff --git a/tests/e2e/support/appRoot.ts b/tests/e2e/support/appRoot.ts index caec45806..fc9ccbc59 100644 --- a/tests/e2e/support/appRoot.ts +++ b/tests/e2e/support/appRoot.ts @@ -41,7 +41,9 @@ * left to get in the wrong order. */ -import { expect, type Page } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' /** * The app id, and therefore the path `generateUrl` is asked to resolve. diff --git a/tests/e2e/visual/_visual-helpers.ts b/tests/e2e/visual/_visual-helpers.ts index c0b1d3e8b..7e27b6563 100644 --- a/tests/e2e/visual/_visual-helpers.ts +++ b/tests/e2e/visual/_visual-helpers.ts @@ -1,3 +1,6 @@ +import type { Locator, Page } from '@playwright/test' + +import { dismissSupportDialog } from '@conduction/nextcloud-vue/testing/playwright' /* * SPDX-License-Identifier: EUPL-1.2 * @@ -24,8 +27,7 @@ * own baselines on first run, or (b) stay non-gating until baselined in the CI * environment. See tests/e2e/visual/README in-repo wiring notes. */ -import { expect, type Page, type Locator } from '@playwright/test' -import { dismissSupportDialog } from '@conduction/nextcloud-vue/testing/playwright' +import { expect } from '@playwright/test' /** Common screenshot options applied to every visual assertion. */ export const SHOT_OPTIONS = { diff --git a/tests/e2e/visual/integriq.visual.spec.ts b/tests/e2e/visual/integriq.visual.spec.ts index 8e1e527b6..04c5e261a 100644 --- a/tests/e2e/visual/integriq.visual.spec.ts +++ b/tests/e2e/visual/integriq.visual.spec.ts @@ -11,7 +11,7 @@ * See _visual-helpers.ts for the platform-rendering caveat. */ import { test } from '@playwright/test' -import { shootSurface } from './_visual-helpers' +import { shootSurface } from './_visual-helpers.ts' const APP = '/index.php/apps/integriq' diff --git a/tests/e2e/workflows/_fixture.ts b/tests/e2e/workflows/_fixture.ts index 857f6c15b..715ccd735 100644 --- a/tests/e2e/workflows/_fixture.ts +++ b/tests/e2e/workflows/_fixture.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext, Browser } from '@playwright/test' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -40,11 +42,7 @@ * `e2e-` prefix in its name/title so afterAll cleanup can find and * delete exactly the rows this run created and nothing else. */ -import { - type APIRequestContext, - type Browser, - request as pwRequest, -} from '@playwright/test' +import { request as pwRequest } from '@playwright/test' import * as path from 'path' export const OR_BASE = '/index.php/apps/openregister/api/objects/integriq' @@ -72,7 +70,6 @@ async function fetchRequestToken(browser: Browser): Promise { }) // OC.requestToken is the canonical source; fall back to the meta. const token = await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const oc = (window as any).OC if ( oc @@ -131,9 +128,9 @@ export async function makeApiClient( } /** Unwrap an OR object-create/get response into the bare object record. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any + function unwrap(body: any): any { - if (body == null) return body + if (body === null || body === undefined) return body // create/update return the object directly or under a key; get returns the object. if (body['@self'] && body.id) return body if (body.object) return body.object @@ -148,9 +145,8 @@ function unwrap(body: any): any { export async function createObject( api: ApiClient, schema: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record, - // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const resp = await api.request.post(`${OR_BASE}/${schema}`, { data, @@ -165,7 +161,7 @@ export async function createObject( } /** find — GET a single object by id/uuid. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export async function find( api: ApiClient, schema: string, @@ -187,7 +183,6 @@ export async function findAll( api: ApiClient, schema: string, query: Record = {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const qs = new URLSearchParams({ _limit: '200', @@ -214,9 +209,8 @@ export async function updateObject( api: ApiClient, schema: string, id: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record, - // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const resp = await api.request.put(`${OR_BASE}/${schema}/${id}`, { data, @@ -241,7 +235,7 @@ export async function deleteObject( }) if (!resp.ok() && resp.status() !== 404) { // Cleanup must be best-effort; log but don't throw so afterAll keeps going. - // eslint-disable-next-line no-console + console.warn( `deleteObject(${schema}/${id}) returned ${resp.status()}: ${await resp.text()}`, ) @@ -257,7 +251,7 @@ export async function cleanupByPrefix( schema: string, prefix: string, ): Promise { - let rows: unknown[] = [] + let rows: unknown[] try { rows = await findAll(api, schema, { _search: prefix }) } catch { @@ -276,7 +270,7 @@ export async function cleanupByPrefix( } /** Extract the stable id (uuid or id) from a persisted OR record. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export function idOf(obj: any): string { return String(obj?.id ?? obj?.uuid ?? obj?.['@self']?.id ?? '') } diff --git a/tests/e2e/workflows/source-mapping-crud.spec.ts b/tests/e2e/workflows/source-mapping-crud.spec.ts index dee9590c9..446b061d3 100644 --- a/tests/e2e/workflows/source-mapping-crud.spec.ts +++ b/tests/e2e/workflows/source-mapping-crud.spec.ts @@ -1,3 +1,6 @@ +import type { Page } from '@playwright/test' +import type { ApiClient } from './_fixture.ts' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -43,18 +46,16 @@ * sort, so these specs no longer encode an assumption about how many rows the * rest of the suite happens to leave behind. */ -import { test, expect, type Page } from '@playwright/test' -import { appDialog } from '../support/dialogs' +import { expect, test } from '@playwright/test' +import { appDialog } from '../support/dialogs.ts' import { - makeApiClient, - makeRunId, + cleanupByPrefix, find, findAll, - deleteObject, - cleanupByPrefix, idOf, - type ApiClient, -} from './_fixture' + makeApiClient, + makeRunId, +} from './_fixture.ts' let api: ApiClient const RUN = makeRunId() diff --git a/tests/e2e/workflows/synchronization-workflow.spec.ts b/tests/e2e/workflows/synchronization-workflow.spec.ts index 4f431fb32..0623c9529 100644 --- a/tests/e2e/workflows/synchronization-workflow.spec.ts +++ b/tests/e2e/workflows/synchronization-workflow.spec.ts @@ -1,3 +1,5 @@ +import type { ApiClient } from './_fixture.ts' + /* * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -42,19 +44,16 @@ * fixme bodies are written to PASS the moment the bugs are fixed, so they * double as regression guards. */ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import { - makeApiClient, - makeRunId, + cleanupByPrefix, createObject, - idOf, findAll, - deleteObject, - cleanupByPrefix, - OR_BASE, + idOf, + makeApiClient, + makeRunId, OC_API, - type ApiClient, -} from './_fixture' +} from './_fixture.ts' const RUN = makeRunId() diff --git a/tests/l10n/check-l10n-parity.js b/tests/l10n/check-l10n-parity.js index 70e99a9a9..460ccf467 100644 --- a/tests/l10n/check-l10n-parity.js +++ b/tests/l10n/check-l10n-parity.js @@ -89,7 +89,7 @@ function loadJsonSet (file) { /** True when a translation value is empty (string) or has an empty plural. */ function isEmpty (v) { - if (v == null) { + if ((v === null || v === undefined)) { return true } if (Array.isArray(v)) { @@ -135,8 +135,8 @@ for (const set of sets) { failures.push({ set: set.kind, loc, kind: 'UNPARSEABLE', detail: e.message }) continue } - const missing = enKeys.filter((k) => !Object.prototype.hasOwnProperty.call(locObj, k)) - const empty = enKeys.filter((k) => Object.prototype.hasOwnProperty.call(locObj, k) && isEmpty(locObj[k])) + const missing = enKeys.filter((k) => !Object.hasOwn(locObj, k)) + const empty = enKeys.filter((k) => Object.hasOwn(locObj, k) && isEmpty(locObj[k])) if (missing.length || empty.length) { failures.push({ set: set.kind, loc, kind: 'INCOMPLETE', missing, empty, total: enKeys.length }) } diff --git a/tests/l10n/check-l10n.js b/tests/l10n/check-l10n.js index 8d1492933..4fb347ed4 100644 --- a/tests/l10n/check-l10n.js +++ b/tests/l10n/check-l10n.js @@ -1,7 +1,5 @@ #!/usr/bin/env node -/* eslint-disable n/no-process-exit */ -/* eslint-disable no-console */ -/* eslint-disable n/shebang */ + /** * l10n extraction / drift check — FRONTEND catalogue. * @@ -69,7 +67,6 @@ const fs = require('fs') const path = require('path') - const { loadJsTranslations, serializeJs, @@ -132,7 +129,7 @@ for (const file of files) { const missing = [] for (const [key, locations] of used) { - if (!Object.prototype.hasOwnProperty.call(translations, key)) { + if (!Object.hasOwn(translations, key)) { missing.push({ key, locations: [...locations] }) } } diff --git a/tests/validate-json-strict.js b/tests/validate-json-strict.js index 8a27cecf1..cd1982f64 100644 --- a/tests/validate-json-strict.js +++ b/tests/validate-json-strict.js @@ -45,7 +45,6 @@ function targetFiles() { // `pathPrefix` is the JSON-pointer-ish path used in the error message. function parseStrict(text, label) { const dupErrors = [] - const reviverPathStack = [] // JSON.parse's reviver can't see duplicates (the object is already // collapsed). So we re-implement just enough: tokenise object keys. // Simpler robust approach: walk the raw text with a tiny tokenizer. @@ -101,7 +100,7 @@ function parseStrict(text, label) { return } let idx = 0 - // eslint-disable-next-line no-constant-condition + while (true) { readValue(`${pathStr}/${idx}`) idx++ @@ -126,7 +125,7 @@ function parseStrict(text, label) { i++ return } - // eslint-disable-next-line no-constant-condition + while (true) { skipWs() if (text[i] !== '"') err('expected string key in object') diff --git a/tests/validate-manifest.js b/tests/validate-manifest.js index f7c484274..4c83c2c8e 100644 --- a/tests/validate-manifest.js +++ b/tests/validate-manifest.js @@ -91,8 +91,8 @@ function loadAjv() { // The canonical schema uses JSON Schema draft 2020-12. Standard Ajv (v7+) // does not auto-load the 2020 meta-schema; we need the `ajv/dist/2020` // entry point. - let Ajv2020 = null - let addFormats = null + let Ajv2020 + let addFormats try { // Ajv 8+ ships the 2020 draft entry point. Ajv2020 = require('ajv/dist/2020').default || require('ajv/dist/2020') diff --git a/tests/vitest/actionFormsShared.spec.js b/tests/vitest/actionFormsShared.spec.js index a873d166b..33a65b5a2 100644 --- a/tests/vitest/actionFormsShared.spec.js +++ b/tests/vitest/actionFormsShared.spec.js @@ -12,14 +12,14 @@ * @nextcloud/axios is mocked; @nextcloud/router is the stub from the config. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const get = vi.fn() vi.mock('@nextcloud/axios', () => ({ default: { get: (...a) => get(...a) } })) import { - patchMethod, fetchOpenRegisterCollection, + patchMethod, valueProp, } from '../../src/views/Rule/actionForms/shared.js' diff --git a/tests/vitest/buildAuthenticationConfiguration.spec.js b/tests/vitest/buildAuthenticationConfiguration.spec.js index 7c6667a9f..a29d4ee53 100644 --- a/tests/vitest/buildAuthenticationConfiguration.spec.js +++ b/tests/vitest/buildAuthenticationConfiguration.spec.js @@ -13,7 +13,7 @@ * harness is node-env and mounts no .vue, so the logic lives in this pure helper by design). */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { buildAuthenticationConfiguration } from '../../src/modals/Rule/buildAuthenticationConfiguration.js' describe('buildAuthenticationConfiguration', () => { @@ -26,7 +26,7 @@ describe('buildAuthenticationConfiguration', () => { apiKeys: [{ apiKey: '', user: [] }], }) - expect(Object.prototype.hasOwnProperty.call(auth, 'keys')).toBe(false) + expect(Object.hasOwn(auth, 'keys')).toBe(false) expect(auth).toEqual({ type: 'api-key', users: [], groups: [] }) }) @@ -36,7 +36,7 @@ describe('buildAuthenticationConfiguration', () => { users: [], groups: [], }) - expect(Object.prototype.hasOwnProperty.call(auth, 'keys')).toBe(false) + expect(Object.hasOwn(auth, 'keys')).toBe(false) }) it('OMITS keys when rows are incomplete (apiKey without a selected user, or user without a key)', () => { @@ -49,7 +49,7 @@ describe('buildAuthenticationConfiguration', () => { { apiKey: '', user: { id: 'alice' } }, ], }) - expect(Object.prototype.hasOwnProperty.call(auth, 'keys')).toBe(false) + expect(Object.hasOwn(auth, 'keys')).toBe(false) }) it('EMITS keys (as apiKey => userId maps) only for complete new rows', () => { diff --git a/tests/vitest/catalogStore.spec.js b/tests/vitest/catalogStore.spec.js index b7e0c7319..b77afdf18 100644 --- a/tests/vitest/catalogStore.spec.js +++ b/tests/vitest/catalogStore.spec.js @@ -16,8 +16,8 @@ * @spec openspec/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001 */ -import { describe, it, expect, vi, beforeEach } from 'vitest' import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' const get = vi.fn() const post = vi.fn() diff --git a/tests/vitest/consumerDraft.spec.js b/tests/vitest/consumerDraft.spec.js index 14091e219..939a2f737 100644 --- a/tests/vitest/consumerDraft.spec.js +++ b/tests/vitest/consumerDraft.spec.js @@ -26,19 +26,19 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { AUTHORIZATION_TYPES, - CREDENTIALLESS_AUTHORIZATION_TYPES, - QUOTA_PERIODS, buildConsumerPayload, buildQuota, buildRateLimit, carriesCredential, consumerDraftFromItem, + CREDENTIALLESS_AUTHORIZATION_TYPES, emptyConsumerDraft, normaliseList, positiveIntOrNull, + QUOTA_PERIODS, } from '../../src/modals/v2/consumerDraft.js' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') diff --git a/tests/vitest/editorModalSlotContract.spec.js b/tests/vitest/editorModalSlotContract.spec.js index 18515ec78..3ac50ebbd 100644 --- a/tests/vitest/editorModalSlotContract.spec.js +++ b/tests/vitest/editorModalSlotContract.spec.js @@ -30,7 +30,7 @@ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' const here = path.dirname(fileURLToPath(import.meta.url)) @@ -88,7 +88,6 @@ function loadSfcOptions(relPath) { .join('\n') const body = `${stubs}\n${script.replace('export default', 'return')}` - // eslint-disable-next-line no-new-func return new Function(body)() } diff --git a/tests/vitest/formsBridge.spec.js b/tests/vitest/formsBridge.spec.js index 0069acd26..3c65a8cb0 100644 --- a/tests/vitest/formsBridge.spec.js +++ b/tests/vitest/formsBridge.spec.js @@ -13,16 +13,16 @@ * @spec openspec/changes/nextcloud-forms-connector/specs/sync-editor-ui/spec.md#requirement-field-mapping-helper-prefilled-from-form-questions-req-syncui-009 */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - NEXTCLOUD_FORM_KIND, - MULTI_VALUE_QUESTION_TYPES, + ambiguousQuestionTexts, extractResults, + isArrayValuedQuestion, mapFormOptions, - normaliseQuestion, mapQuestionDescriptors, - isArrayValuedQuestion, - ambiguousQuestionTexts, + MULTI_VALUE_QUESTION_TYPES, + NEXTCLOUD_FORM_KIND, + normaliseQuestion, } from '../../src/views/Synchronization/formsBridge.js' describe('kind discriminator', () => { diff --git a/tests/vitest/jobDraft.spec.js b/tests/vitest/jobDraft.spec.js index 4d692e931..6db03752a 100644 --- a/tests/vitest/jobDraft.spec.js +++ b/tests/vitest/jobDraft.spec.js @@ -23,14 +23,14 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - SYNCHRONIZATION_ACTION_CLASS, coerceNumber, dateValueFromStored, formatDateValue, groupFieldRuns, readSynchronizationId, + SYNCHRONIZATION_ACTION_CLASS, writeSynchronizationId, } from '../../src/modals/v2/jobDraft.js' diff --git a/tests/vitest/liveObjectSubscriptionCleanup.spec.js b/tests/vitest/liveObjectSubscriptionCleanup.spec.js index bafa7dee4..bd2e28678 100644 --- a/tests/vitest/liveObjectSubscriptionCleanup.spec.js +++ b/tests/vitest/liveObjectSubscriptionCleanup.spec.js @@ -1,3 +1,4 @@ +import { mount } from '@vue/test-utils' /** * @vitest-environment jsdom * @@ -21,8 +22,7 @@ * * @spec openspec/specs/realtime-updates/spec.md */ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' const unsubscribe = vi.fn() diff --git a/tests/vitest/ncButtonSubmitType.spec.js b/tests/vitest/ncButtonSubmitType.spec.js index c734177b4..442b667fa 100644 --- a/tests/vitest/ncButtonSubmitType.spec.js +++ b/tests/vitest/ncButtonSubmitType.spec.js @@ -25,9 +25,9 @@ * the negative controls fail loudly rather than silently passing. */ -import { describe, it, expect } from 'vitest' -import { defineComponent, h } from 'vue' import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent, h } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' /** diff --git a/tests/vitest/routerRef.spec.js b/tests/vitest/routerRef.spec.js index 9bcdde5b6..ea33fc636 100644 --- a/tests/vitest/routerRef.spec.js +++ b/tests/vitest/routerRef.spec.js @@ -7,8 +7,8 @@ * getRouter() (they run without Vue component context, so no this.$router). */ -import { describe, it, expect, beforeEach } from 'vitest' -import { setRouter, getRouter } from '../../src/handlers/routerRef.js' +import { beforeEach, describe, expect, it } from 'vitest' +import { getRouter, setRouter } from '../../src/handlers/routerRef.js' describe('routerRef', () => { beforeEach(() => { diff --git a/tests/vitest/ruleDraft.spec.js b/tests/vitest/ruleDraft.spec.js index 1710ed52a..60115bdce 100644 --- a/tests/vitest/ruleDraft.spec.js +++ b/tests/vitest/ruleDraft.spec.js @@ -23,17 +23,17 @@ * unknown type. A typo here is a rule that cannot run. */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { ACTION_OPTIONS, ACTION_TYPES, DEFAULT_ERROR_CONFIG, EMPTY_ROOT_GROUP, - TIMING_OPTIONS, - UNDISPATCHED_ACTION_TYPES, emptyRuleDraft, normaliseConditions, serializeRuleConditions, + TIMING_OPTIONS, + UNDISPATCHED_ACTION_TYPES, } from '../../src/views/Rule/ruleDraft.js' describe('normaliseConditions', () => { diff --git a/tests/vitest/runTargets.spec.js b/tests/vitest/runTargets.spec.js index cf090527d..d2e759b12 100644 --- a/tests/vitest/runTargets.spec.js +++ b/tests/vitest/runTargets.spec.js @@ -19,12 +19,12 @@ * @nextcloud/l10n is aliased to a deterministic stub in vitest.config.js. */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { + countUuids, getRunDescriptor, initialOptionValues, visibleOptions, - countUuids, } from '../../src/modals/v2/runTargets.js' describe('getRunDescriptor', () => { diff --git a/tests/vitest/sourceCredentialRef.spec.js b/tests/vitest/sourceCredentialRef.spec.js index 1e62227f2..f4a658ad6 100644 --- a/tests/vitest/sourceCredentialRef.spec.js +++ b/tests/vitest/sourceCredentialRef.spec.js @@ -15,17 +15,17 @@ * • the OR list-envelope unwrap + NcSelect option mapping (soft-fail-safe). */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - EMBEDDED_SECRET_FIELDS, CALLING_APP_ID, - readCredentialRef, - isBrokered, - readCredentialId, - writeCredentialRef, clearCredentialRef, + EMBEDDED_SECRET_FIELDS, extractCredentialResults, + isBrokered, mapCredentialOptions, + readCredentialId, + readCredentialRef, + writeCredentialRef, } from '../../src/modals/v2/sourceCredentialRef.js' const UUID = '00000000-0000-0000-0000-000000000000' diff --git a/tests/vitest/tablesBridge.spec.js b/tests/vitest/tablesBridge.spec.js index 71d168a88..097df0a7a 100644 --- a/tests/vitest/tablesBridge.spec.js +++ b/tests/vitest/tablesBridge.spec.js @@ -12,17 +12,17 @@ * @spec openspec/changes/tables-bridge/specs/sync-editor-ui/spec.md#requirement-column-mapping-helper-prefilled-from-table-schema-req-syncui-007 */ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - NEXTCLOUD_TABLE_KIND, + columnTypeHint, extractResults, + mapColumnDescriptors, + mappedValueFor, mapTableOptions, + NEXTCLOUD_TABLE_KIND, normaliseColumn, - mapColumnDescriptors, - columnTypeHint, readColumnMapping, upsertColumnMapping, - mappedValueFor, } from '../../src/views/Synchronization/tablesBridge.js' describe('kind discriminator', () => { From fcac77b67718e9a267db7208153782c025e5ddf1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 14:07:21 +0200 Subject: [PATCH 13/22] fix(e2e): make the suite load again after the lint sweep (#1801) The lint pass left two shapes that eslint and prettier both call clean while the Playwright suite fails to load. `development` collected ZERO tests. A Playwright callback's destructuring is a FIXTURE REQUEST, not a binding list. Renaming an unused `request` to `_request` asks for a fixture that does not exist and the file never loads. The binding is renamed, the key kept: async ({ page, request: _request }) => { And the type-import conversion added a second `import type { Page }` to a file that already imported `Page` alongside `ConsoleMessage`, which is a duplicate-identifier SyntaxError. `npx playwright test --list` parses every spec without running one, and is the check that catches both. A green linter cannot. --- tests/e2e/regression/manifest-pages.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/regression/manifest-pages.spec.ts b/tests/e2e/regression/manifest-pages.spec.ts index 0a3a42a91..b02e0aad1 100644 --- a/tests/e2e/regression/manifest-pages.spec.ts +++ b/tests/e2e/regression/manifest-pages.spec.ts @@ -43,7 +43,6 @@ * - src/manifest.json */ -import type { Page } from '@playwright/test' import type { ConsoleMessage, Page } from '@playwright/test' import { expect, test } from '@playwright/test' From ae5499c12fb5b2792b3ee13185dff1985af97b38 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 21:04:44 +0200 Subject: [PATCH 14/22] fix(router): resolve deep links in both URL forms (#1805) Nextcloud serves an app under both /apps/integriq/... and /index.php/apps/integriq/..., but generateUrl() returns only the form the instance is configured for. Used as the vue-router base, that means a visitor arriving on the other form has a pathname the router cannot strip its base from. No route matches, the catch-all takes over, and they land on the dashboard with no error at all. Measured live on learniq, which had the identical base, across all 282 of its routes: /apps/learniq/courses resolved to Courses, and /index.php/apps/learniq/courses resolved to the dashboard. Not one broken page, every deep link in that URL form. routerBase() derives the base from the pathname, so it always matches the URL the visitor actually arrived on. openregister, opencatalogi, stackiq, larpinq, zaakafhandelapp, pipelinq and keepiq already do exactly this. --- src/main.js | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index 19779bd47..ac6d1c568 100644 --- a/src/main.js +++ b/src/main.js @@ -185,6 +185,33 @@ function routesFromManifest(manifest) { return routes } +/** + * The router base for THIS page load. + * + * ⚠️ `generateUrl('/apps/integriq')` alone is not enough. Nextcloud serves the + * app under BOTH `/apps/integriq/...` and `/index.php/apps/integriq/...`, but + * `generateUrl()` returns only the form the instance is configured for. A + * visitor arriving on the other form — a bookmark, an emailed deep link, an + * integration that hardcodes `/index.php` — has a pathname the router cannot + * strip its base from. No route matches, the catch-all takes over, and they + * land on the dashboard with no error at all: the deep link is silently + * swallowed. + * + * Measured on a live instance for learniq, across all 282 of its routes: + * `/apps/learniq/courses` resolved to Courses, `/index.php/apps/learniq/courses` + * resolved to the dashboard. Every route behaved the same way, so this is not + * one broken page but every deep link in that URL form. + * + * Deriving the base from the pathname makes both forms resolve, because the + * base then always matches the URL the visitor actually arrived on. + * + * @return {string} The base path vue-router should strip from the URL. + */ +function routerBase() { + const match = window.location.pathname.match(/^(.*\/apps\/integriq)(?:\/|$)/) + return match ? match[1] : generateUrl('/apps/integriq') +} + const router = createRouter({ // Path-based history, not hash. The server-side half of this was already // built (ui#dashboard's `/{path}` catch-all in appinfo/routes.php, from the @@ -192,7 +219,7 @@ const router = createRouter({ // still 404 correctly) and verified live before this line changed: a // never-before-hit deep path returned 200/text-html. This was the only // missing piece. - history: createWebHistory(generateUrl('/apps/integriq')), + history: createWebHistory(routerBase()), routes: routesFromManifest(mergedManifest), }) From e6e3f573d3e3a420acfe004eb3300f5c5a532be9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 21:50:53 +0200 Subject: [PATCH 15/22] test(e2e): both URL prefixes now resolve, so invert the positive control (#1807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1805 made routerBase() derive the router base from window.location.pathname instead of generateUrl(), so a deep link resolves under BOTH /apps/integriq and /index.php/apps/integriq. That broke this file's third test, which asserted the OTHER prefix was redirected to the app root. The test was right when it was written and the fix is what invalidated it. Its own closing comment said so: 'if this assertion ever fails, the prefix distinction has stopped mattering and appRoot.ts can be simplified — do that rather than deleting this test.' So it is inverted, not deleted. It now walks both prefixes and requires each to KEEP its route and not land on the Dashboard. The guard keeps a job: it no longer proves the resolver picks the right prefix, it proves the silently swallowed deep link cannot come back — which is the failure this app already paid for once, with 36 specs in manifest-pages.spec.ts passing against the Dashboard. appRoot.ts is left alone. It is no longer load-bearing for correctness, since either prefix now works, but it is still correct and still used by six spec files, and simplifying it is a separate change from proving the bug is gone. --- .../regression/spa-root-resolution.spec.ts | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/tests/e2e/regression/spa-root-resolution.spec.ts b/tests/e2e/regression/spa-root-resolution.spec.ts index 70f48f80a..b32c1c78d 100644 --- a/tests/e2e/regression/spa-root-resolution.spec.ts +++ b/tests/e2e/regression/spa-root-resolution.spec.ts @@ -22,10 +22,29 @@ * * 1. the resolver returns what the app itself would compute; * 2. a deep link through it MATCHES its route (address bar unchanged); - * 3. ⚠️ POSITIVE CONTROL — the same deep link under the OTHER prefix is - * redirected to the app root. Without this, parts 1 and 2 could both be - * green on an instance where the distinction does not exist, and the guard - * would be worthless in exactly the environment it was written for. + * 3. BOTH prefixes resolve the route. + * + * ⚠️ Part 3 used to be the opposite claim — a POSITIVE CONTROL asserting the + * OTHER prefix was redirected to the app root — and it was right at the time. + * The router base came from `generateUrl('/apps/integriq')`, which returns only + * the form the instance is configured for, so a visitor who arrived on the other + * form fell outside the base and was swallowed to the Dashboard. + * + * `routerBase()` now derives the base from `window.location.pathname`, so it + * always matches the URL the visitor actually arrived on and both forms work. + * That fix is what made the old control fail, exactly as its own closing comment + * predicted: "if this assertion ever fails, the prefix distinction has stopped + * mattering". + * + * The guard was inverted rather than deleted, because it still has a job. It no + * longer proves the resolver picks the right prefix; it proves the swallowed + * deep link cannot come back. That matters beyond this suite: these URLs are + * pasted into tickets and mails by people who never see which form their client + * produced. + * + * `appRoot.ts` is therefore no longer load-bearing for correctness — either + * prefix would now do — but it is still correct, still used by six spec files, + * and simplifying it is a separate change from proving the bug is gone. */ import { expect, test } from '@playwright/test' @@ -89,33 +108,48 @@ test.describe('SPA root resolution (path-mode router base)', () => { ).toHaveCount(0) }) - test('POSITIVE CONTROL: the same route under the OTHER prefix falls through to the app root', async ({ + test('BOTH prefixes resolve the route — the distinction no longer exists', async ({ page, }) => { - const root = await resolveAppRoot(page) - const wrong = BOTH_PREFIXES.find((prefix) => prefix !== root) - expect(wrong, 'there must be a second prefix to test against').toBeTruthy() + // This was a POSITIVE CONTROL asserting the OTHER prefix fell through to + // the app root. It was correct when the router base came from + // `generateUrl('/apps/integriq')`, which returns only the form the + // instance is configured for, leaving the other form outside the base. + // + // `routerBase()` now derives the base from `window.location.pathname`, so + // it always matches the URL the visitor actually arrived on and BOTH forms + // resolve. The old control failed against that fix, exactly as its own + // closing comment predicted it would: "if this assertion ever fails, the + // prefix distinction has stopped mattering". + // + // So the assertion is inverted rather than deleted. The guard still has a + // job — it is now what proves the swallowed deep link cannot come back. + for (const prefix of BOTH_PREFIXES) { + await page.goto(`${prefix}${ROUTE}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }) - await page.goto(`${wrong}${ROUTE}`, { - waitUntil: 'domcontentloaded', - timeout: 30_000, - }) + // The SPA must have mounted — otherwise this proves nothing about + // routing, only that the page failed to load. + await expect( + page + .locator('#app-content, [data-cy=app-content], .app-content') + .first(), + `${prefix}${ROUTE} must serve the SPA shell`, + ).toBeVisible({ timeout: 15_000 }) - // The SPA must have mounted — otherwise this proves nothing about - // routing, only that the page failed to load. - await expect( - page - .locator('#app-content, [data-cy=app-content], .app-content') - .first(), - 'the wrong prefix must still SERVE the SPA shell — that is precisely why the old probe could not tell the two apart', - ).toBeVisible({ timeout: 15_000 }) + // …and the router must have KEPT the route. A redirect to the app + // root is the silent deep-link swallow this app already paid for once. + expect( + new URL(page.url()).pathname, + `${prefix}${ROUTE} was redirected to the app root — the deep link was swallowed`, + ).toContain(ROUTE) - // …and the router must have thrown the route away. If this assertion - // ever fails, the prefix distinction has stopped mattering and - // appRoot.ts can be simplified — do that rather than deleting this test. - expect( - new URL(page.url()).pathname, - `${wrong}${ROUTE} was expected to fall through the catch-all to the app root`, - ).not.toContain(ROUTE) + await expect( + page.getByRole('heading', { name: 'Dashboard', level: 2 }), + `${prefix}${ROUTE} landed on the Dashboard`, + ).toHaveCount(0) + } }) }) From c11b2eb93e5e5e63d0849bd7635db779d39b1d59 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 13:40:55 +0200 Subject: [PATCH 16/22] feat(events): ADR-041 delivery seam for sibling-app outbound deliveries (#1810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(events): ADR-041 delivery seam — typed request/concluded events over the CloudEvents pipeline * style(events): spec tags on the seam contract, justify the flat provenance ctor, fix the attempt-shape assertions --- lib/AppInfo/Application.php | 7 + lib/Event/DeliveryConcludedEvent.php | 198 ++++++++++++ lib/Event/DeliveryRequestedEvent.php | 301 ++++++++++++++++++ .../DeliveryRequestedListener.php | 100 ++++++ lib/Service/EventService.php | 169 ++++++++++ .../absorb-dossiq-deliveries/design.md | 47 +++ .../absorb-dossiq-deliveries/proposal.md | 74 +++++ .../specs/delivery-intake/spec.md | 86 +++++ .../changes/absorb-dossiq-deliveries/tasks.md | 42 +++ .../DeliveryRequestedListenerTest.php | 146 +++++++++ .../Service/EventServiceDeliverySeamTest.php | 284 +++++++++++++++++ tests/Unit/Service/EventServiceTest.php | 10 +- 12 files changed, 1462 insertions(+), 2 deletions(-) create mode 100644 lib/Event/DeliveryConcludedEvent.php create mode 100644 lib/Event/DeliveryRequestedEvent.php create mode 100644 lib/EventListener/DeliveryRequestedListener.php create mode 100644 openspec/changes/absorb-dossiq-deliveries/design.md create mode 100644 openspec/changes/absorb-dossiq-deliveries/proposal.md create mode 100644 openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md create mode 100644 openspec/changes/absorb-dossiq-deliveries/tasks.md create mode 100644 tests/Unit/EventListener/DeliveryRequestedListenerTest.php create mode 100644 tests/Unit/Service/EventServiceDeliverySeamTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1f8c71612..f5fcb1a28 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -42,7 +42,9 @@ use OCA\Integriq\Capabilities; use OCA\Integriq\Controller\HealthController; use OCA\Integriq\Controller\MetricsController; +use OCA\Integriq\Event\DeliveryRequestedEvent; use OCA\Integriq\EventListener\CloudEventListener; +use OCA\Integriq\EventListener\DeliveryRequestedListener; use OCA\Integriq\EventListener\EndpointCacheInvalidationListener; use OCA\Integriq\EventListener\NextcloudCalendarEventListener; use OCA\Integriq\EventListener\NextcloudFileEventListener; @@ -199,6 +201,11 @@ function ($c) { $dispatcher->addServiceListener(eventName: ObjectCreatedEvent::class, className: CloudEventListener::class); $dispatcher->addServiceListener(eventName: ObjectUpdatedEvent::class, className: CloudEventListener::class); $dispatcher->addServiceListener(eventName: ObjectDeletedEvent::class, className: CloudEventListener::class); + // ADR-041 cross-app delivery seam: a sibling app (dossiq, ...) raises + // a typed DeliveryRequestedEvent; this listener ingests it into the + // same CloudEvents pipeline (subscription routing, retry, dead-letter, + // replay) and writes the synchronous result slot back on the event. + $dispatcher->addServiceListener(eventName: DeliveryRequestedEvent::class, className: DeliveryRequestedListener::class); // Nextcloud-core-event triggers (nextcloud-event-hub). Each family // normalizes its NC event into the SAME `event` CloudEvents envelope // shape the OR-object pipeline above already uses, then hands off to diff --git a/lib/Event/DeliveryConcludedEvent.php b/lib/Event/DeliveryConcludedEvent.php new file mode 100644 index 000000000..545ce7da1 --- /dev/null +++ b/lib/Event/DeliveryConcludedEvent.php @@ -0,0 +1,198 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Terminal outcome of a cross-app delivery request. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) -- the ADR-041 event contract is a flat + * readonly provenance envelope (sourceApp, subject coordinates, kind/channel, correlation); + * folding fields into an array would untype the contract the consumer stubs must mirror + * verbatim. Mirrors the decidiq DecisionRequestedEvent precedent. + */ +class DeliveryConcludedEvent extends Event { + /** + * Terminal status: the delivery succeeded. + */ + public const STATUS_DELIVERED = 'delivered'; + + /** + * Terminal status: the retry budget is spent, no further attempts. + */ + public const STATUS_ABANDONED = 'abandoned'; + + /** + * Constructor. + * + * @param string $sourceApp The app that raised the original request. + * @param string $correlationId The caller's correlation id, echoed verbatim. + * @param string $subjectId The subject object id from the original request. + * @param string $channel The delivery channel from the original request. + * @param string $status Terminal status: {@see self::STATUS_DELIVERED} or {@see self::STATUS_ABANDONED}. + * @param string $eventId Uuid of the CloudEvent `event` object. + * @param string $messageId Uuid of the `event_message` delivery record. + * @param int $attempts How many delivery attempts were made. + * @param string|null $error The last delivery error, or null on success. + * @param string $concludedAt ISO 8601 timestamp of the terminal transition. + * + * @return void + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $correlationId, + private readonly string $subjectId, + private readonly string $channel, + private readonly string $status, + private readonly string $eventId, + private readonly string $messageId, + private readonly int $attempts, + private readonly ?string $error, + private readonly string $concludedAt, + ) { + parent::__construct(); + }//end __construct() + + /** + * The app that raised the original request. + * + * @return string The source app id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + }//end getSourceApp() + + /** + * The caller's correlation id. + * + * @return string The correlation id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + }//end getCorrelationId() + + /** + * The subject object id from the original request. + * + * @return string The subject id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectId(): string { + return $this->subjectId; + }//end getSubjectId() + + /** + * The delivery channel from the original request. + * + * @return string The channel. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getChannel(): string { + return $this->channel; + }//end getChannel() + + /** + * Terminal status of the delivery. + * + * @return string One of the STATUS_* constants. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getStatus(): string { + return $this->status; + }//end getStatus() + + /** + * Uuid of the CloudEvent `event` object. + * + * @return string The event uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getEventId(): string { + return $this->eventId; + }//end getEventId() + + /** + * Uuid of the `event_message` delivery record. + * + * @return string The message uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getMessageId(): string { + return $this->messageId; + }//end getMessageId() + + /** + * How many delivery attempts were made. + * + * @return int The attempt count. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getAttempts(): int { + return $this->attempts; + }//end getAttempts() + + /** + * The last delivery error. + * + * @return string|null The error, or null on success. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getError(): ?string { + return $this->error; + }//end getError() + + /** + * When the delivery reached its terminal state. + * + * @return string ISO 8601 timestamp. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getConcludedAt(): string { + return $this->concludedAt; + }//end getConcludedAt() +}//end class diff --git a/lib/Event/DeliveryRequestedEvent.php b/lib/Event/DeliveryRequestedEvent.php new file mode 100644 index 000000000..be051530c --- /dev/null +++ b/lib/Event/DeliveryRequestedEvent.php @@ -0,0 +1,301 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Typed cross-app command: "deliver this payload on my behalf". + * + * Carries provenance (which app, which subject object), a delivery payload + * reference, and a synchronous result slot the in-process listener writes: + * `isHandled()` + `getResultId()` (the persisted CloudEvent uuid) + + * `getMatchedSubscriptions()` (how many delivery routes picked it up — zero + * means the request was accepted but nothing is configured to deliver it, + * which a fail-closed consumer records as a refusal, not a success). + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) -- the ADR-041 event contract is a flat + * readonly provenance envelope (sourceApp, subject coordinates, kind/channel, correlation); + * folding fields into an array would untype the contract the consumer stubs must mirror + * verbatim. Mirrors the decidiq DecisionRequestedEvent precedent. + */ +class DeliveryRequestedEvent extends Event { + /** + * Whether an Integriq listener handled the request. + * + * @var bool + */ + private bool $handled = false; + + /** + * Uuid of the persisted CloudEvent `event` object, once handled. + * + * @var string|null + */ + private ?string $resultId = null; + + /** + * How many active event subscriptions matched the delivery request. + * + * @var int + */ + private int $matchedSubscriptions = 0; + + /** + * Constructor. + * + * @param string $sourceApp The requesting app id (e.g. `dossiq`). + * @param string $subjectRegister The OpenRegister register slug/id of the subject object. + * @param string $subjectSchema The schema slug/id of the subject object. + * @param string $subjectId The subject object id/uuid (e.g. the case id). + * @param string $subjectLabel Human-readable label for the subject. + * @param string $deliveryKind What is being delivered (e.g. `besluit-publication`). + * @param string $channel The requested delivery channel (e.g. `gemeenteblad`). + * @param array $payload The delivery payload reference (composed by the source app). + * @param string $correlationId Caller-generated id echoed on the concluded event. + * @param string|null $externalReference Optional external reference (e.g. besluit identificatie). + * @param string|null $userId The acting Nextcloud user, or null for system-produced requests. + * + * @return void + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $subjectRegister, + private readonly string $subjectSchema, + private readonly string $subjectId, + private readonly string $subjectLabel, + private readonly string $deliveryKind, + private readonly string $channel, + private readonly array $payload, + private readonly string $correlationId, + private readonly ?string $externalReference = null, + private readonly ?string $userId = null, + ) { + parent::__construct(); + }//end __construct() + + /** + * The requesting app id. + * + * @return string The source app id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + }//end getSourceApp() + + /** + * The subject object's register. + * + * @return string The register slug/id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectRegister(): string { + return $this->subjectRegister; + }//end getSubjectRegister() + + /** + * The subject object's schema. + * + * @return string The schema slug/id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectSchema(): string { + return $this->subjectSchema; + }//end getSubjectSchema() + + /** + * The subject object id. + * + * @return string The object id/uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectId(): string { + return $this->subjectId; + }//end getSubjectId() + + /** + * Human-readable subject label. + * + * @return string The label. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectLabel(): string { + return $this->subjectLabel; + }//end getSubjectLabel() + + /** + * What is being delivered. + * + * @return string The delivery kind. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getDeliveryKind(): string { + return $this->deliveryKind; + }//end getDeliveryKind() + + /** + * The requested delivery channel. + * + * @return string The channel. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getChannel(): string { + return $this->channel; + }//end getChannel() + + /** + * The delivery payload reference. + * + * @return array The payload. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getPayload(): array { + return $this->payload; + }//end getPayload() + + /** + * The caller's correlation id. + * + * @return string The correlation id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + }//end getCorrelationId() + + /** + * Optional external reference. + * + * @return string|null The external reference. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getExternalReference(): ?string { + return $this->externalReference; + }//end getExternalReference() + + /** + * The acting Nextcloud user. + * + * @return string|null The user id, or null for system-produced requests. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getUserId(): ?string { + return $this->userId; + }//end getUserId() + + /** + * Mark the request as handled by an Integriq listener. + * + * @param bool $handled Whether the request was handled. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setHandled(bool $handled): void { + $this->handled = $handled; + }//end setHandled() + + /** + * Whether an Integriq listener handled the request. + * + * @return bool True when handled. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function isHandled(): bool { + return $this->handled; + }//end isHandled() + + /** + * Record the persisted CloudEvent uuid. + * + * @param string $resultId The event object uuid. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setResultId(string $resultId): void { + $this->resultId = $resultId; + }//end setResultId() + + /** + * The persisted CloudEvent uuid, once handled. + * + * @return string|null The event object uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getResultId(): ?string { + return $this->resultId; + }//end getResultId() + + /** + * Record how many subscriptions matched. + * + * @param int $matchedSubscriptions The matched subscription count. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setMatchedSubscriptions(int $matchedSubscriptions): void { + $this->matchedSubscriptions = $matchedSubscriptions; + }//end setMatchedSubscriptions() + + /** + * How many active subscriptions matched the delivery request. + * + * @return int The matched subscription count. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getMatchedSubscriptions(): int { + return $this->matchedSubscriptions; + }//end getMatchedSubscriptions() +}//end class diff --git a/lib/EventListener/DeliveryRequestedListener.php b/lib/EventListener/DeliveryRequestedListener.php new file mode 100644 index 000000000..d9b05c26b --- /dev/null +++ b/lib/EventListener/DeliveryRequestedListener.php @@ -0,0 +1,100 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\EventListener; + +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\Service\EventService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * Listener that ingests cross-app delivery requests into the CloudEvents + * pipeline. + * + * On success it marks the event handled, records the persisted CloudEvent + * uuid as the result id, and reports the matched-subscription count so a + * fail-closed consumer can distinguish "accepted and routed" from "accepted + * but no delivery route is configured". On ingest failure the event stays + * unhandled — the consumer's fail-closed guard then records a refusal. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ +class DeliveryRequestedListener implements IEventListener { + /** + * Constructor. + * + * @param EventService $eventService The CloudEvents pipeline entry point. + * @param LoggerInterface $logger Logger for ingest failures. + * + * @return void + */ + public function __construct( + private readonly EventService $eventService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle a cross-app delivery request. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function handle(Event $event): void { + if (($event instanceof DeliveryRequestedEvent) === false) { + return; + } + + try { + $result = $this->eventService->ingestDeliveryRequest(request: $event); + } catch (\Throwable $e) { + // Leave the event unhandled: the consumer's fail-closed guard + // records the refusal on its own domain record. + $this->logger->error( + 'Delivery request ingest failed: ' . $e->getMessage(), + [ + 'exception' => $e, + 'sourceApp' => $event->getSourceApp(), + 'correlationId' => $event->getCorrelationId(), + ] + ); + return; + }//end try + + $event->setResultId(resultId: (string)$result['event']->getUuid()); + $event->setMatchedSubscriptions(matchedSubscriptions: count($result['messages'])); + $event->setHandled(handled: true); + }//end handle() +}//end class diff --git a/lib/Service/EventService.php b/lib/Service/EventService.php index 9985c7bcc..b8296833e 100644 --- a/lib/Service/EventService.php +++ b/lib/Service/EventService.php @@ -22,6 +22,8 @@ use DateTime; use Exception; use JWadhams\JsonLogic; +use OCA\Integriq\Event\DeliveryConcludedEvent; +use OCA\Integriq\Event\DeliveryRequestedEvent; use OCA\Integriq\Exception\FormsFeatureDisabledException; use OCA\Integriq\Exception\InvalidMessageStateException; use OCA\Integriq\Service\Forms\FormsAnswerResolver; @@ -30,6 +32,7 @@ use OCA\Integriq\Service\Security\SensitiveFieldRegistry; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\ObjectService as ORObjectService; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; use Psr\Log\LoggerInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; @@ -116,6 +119,17 @@ class EventService { */ public const NEXTCLOUD_SOURCE_PREFIX = '/nextcloud/'; + /** + * CloudEvents `type` for cross-app delivery requests ingested through the + * ADR-041 typed-event seam ({@see DeliveryRequestedEvent}). Subscriptions + * route on this type plus `data.delivery.*` provenance filters. + * + * @var string + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public const DELIVERY_REQUESTED_TYPE = 'nl.conduction.delivery.requested'; + /** * Constructor. * @@ -143,6 +157,10 @@ class EventService { * so pre-existing positional test * instantiations keep working * unmodified. + * @param IEventDispatcher|null $eventDispatcher Dispatches {@see DeliveryConcludedEvent} when a + * provenance-carrying delivery reaches a terminal + * state (ADR-041 seam). Nullable + defaulted for + * the same test-compatibility reason as above. * * @spec openspec/specs/events-cloudevents/spec.md#requirement-a-subscription-s-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008 * @spec openspec/specs/events-cloudevents/spec.md#requirement-a-subscription-s-action-dispatch-must-support-a-notificaties-kind-for-zgw-notificaties-api-publishing-req-010 @@ -162,6 +180,7 @@ public function __construct( private readonly ?FormsAnswerResolver $formsAnswerResolver = null, private readonly ?FormsSyncAdapter $formsSyncAdapter = null, private readonly ?ExecutionTraceService $executionTraceService = null, + private readonly ?IEventDispatcher $eventDispatcher = null, ) { }//end __construct() @@ -787,6 +806,15 @@ private function recordFailure( uuid: $message->getUuid() ); + if ($messageData['status'] === 'abandoned') { + $this->dispatchDeliveryConcluded( + message: $message, + messageData: $messageData, + status: DeliveryConcludedEvent::STATUS_ABANDONED, + concludedAt: $nowIso + ); + } + }//end recordFailure() /** @@ -1819,8 +1847,88 @@ private function recordDeliverySuccess(ObjectEntity $message): void { uuid: $message->getUuid() ); + $this->dispatchDeliveryConcluded( + message: $message, + messageData: $messageData, + status: DeliveryConcludedEvent::STATUS_DELIVERED, + concludedAt: $now + ); + }//end recordDeliverySuccess() + /** + * Dispatch the terminal {@see DeliveryConcludedEvent} for a + * provenance-carrying delivery message (ADR-041 seam). + * + * Gated to messages whose originating event was ingested through + * {@see ingestDeliveryRequest} — the `data.delivery.sourceApp` + + * `correlationId` provenance block is the gate, so ordinary CloudEvent + * traffic never produces a concluded event. Dispatch failures are logged + * and swallowed: the message's own status record is the source of truth + * and must not be rolled back by a consumer-side listener error. + * + * @param ObjectEntity $message The event_message row that reached a terminal state. + * @param array $messageData The message's persisted data (post-transition). + * @param string $status Terminal status: {@see DeliveryConcludedEvent::STATUS_DELIVERED} + * or {@see DeliveryConcludedEvent::STATUS_ABANDONED}. + * @param string $concludedAt ISO 8601 timestamp of the terminal transition. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + private function dispatchDeliveryConcluded( + ObjectEntity $message, + array $messageData, + string $status, + string $concludedAt, + ): void { + if ($this->eventDispatcher === null) { + return; + } + + $payload = (array)($messageData['payload'] ?? []); + $data = (array)($payload['data'] ?? []); + $delivery = (array)($data['delivery'] ?? []); + $sourceApp = (string)($delivery['sourceApp'] ?? ''); + $correlationId = (string)($delivery['correlationId'] ?? ''); + if ($sourceApp === '' || $correlationId === '') { + // Not an ADR-041 delivery request — nothing to conclude. + return; + } + + $error = null; + if (isset($messageData['error']) === true && (string)$messageData['error'] !== '') { + $error = (string)$messageData['error']; + } + + try { + $this->eventDispatcher->dispatchTyped( + new DeliveryConcludedEvent( + sourceApp: $sourceApp, + correlationId: $correlationId, + subjectId: (string)($delivery['subjectId'] ?? ''), + channel: (string)($delivery['channel'] ?? ''), + status: $status, + eventId: (string)($messageData['event'] ?? ''), + messageId: (string)$message->getUuid(), + attempts: count((array)($messageData['attempts'] ?? [])), + error: $error, + concludedAt: $concludedAt, + ) + ); + } catch (\Throwable $e) { + $this->logger->error( + 'DeliveryConcludedEvent dispatch failed: ' . $e->getMessage(), + [ + 'exception' => $e, + 'messageId' => $message->getUuid(), + 'sourceApp' => $sourceApp, + ] + ); + }//end try + }//end dispatchDeliveryConcluded() + /** * Persist a configuration-error failure (e.g. an unrecognised * `action.kind`): `status='failed'` with a descriptive error, WITHOUT @@ -2289,6 +2397,67 @@ public function emitCloudEvent(string $type, string $source, ?string $subject, a return $this->processEvent(event: $event); }//end emitCloudEvent() + /** + * Ingest an ADR-041 cross-app delivery request into the CloudEvents + * pipeline. + * + * Persists a {@see self::DELIVERY_REQUESTED_TYPE} `event` OR object whose + * `data.delivery` block carries the request's provenance (sourceApp, + * subject, channel, correlationId) and whose `data.payload` carries the + * caller-composed delivery payload, then fans it out via + * {@see processEvent} so admin-configured `event_subscription`s route it + * to a webhook / flow / synchronization / notificaties action with the + * pipeline's retry, dead-letter and replay semantics. + * + * The provenance block is what later gates the terminal + * {@see DeliveryConcludedEvent} dispatch in + * {@see dispatchDeliveryConcluded} — ordinary CloudEvent traffic carries + * no `data.delivery` and never produces one. + * + * @param DeliveryRequestedEvent $request The typed cross-app delivery request. + * + * @return array{event: ObjectEntity, messages: ObjectEntity[]} The persisted event and its created delivery messages. + * + * @throws Exception On event processing failure. + * @throws \OCP\DB\Exception On persistence failure. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function ingestDeliveryRequest(DeliveryRequestedEvent $request): array { + $event = $this->objectService->saveObject( + object: [ + 'source' => ('/apps/' . $request->getSourceApp() . '/delivery'), + 'type' => self::DELIVERY_REQUESTED_TYPE, + 'time' => (new DateTime())->format('c'), + 'subject' => $request->getSubjectId(), + 'data' => [ + 'delivery' => [ + 'sourceApp' => $request->getSourceApp(), + 'subjectRegister' => $request->getSubjectRegister(), + 'subjectSchema' => $request->getSubjectSchema(), + 'subjectId' => $request->getSubjectId(), + 'subjectLabel' => $request->getSubjectLabel(), + 'deliveryKind' => $request->getDeliveryKind(), + 'channel' => $request->getChannel(), + 'correlationId' => $request->getCorrelationId(), + 'externalReference' => $request->getExternalReference(), + ], + 'payload' => $request->getPayload(), + ], + 'userId' => $request->getUserId(), + ], + register: 'integriq', + schema: 'event' + ); + + $messages = $this->processEvent(event: $event); + + return [ + 'event' => $event, + 'messages' => $messages, + ]; + }//end ingestDeliveryRequest() + /** * Normalize a Nextcloud-native core event (files/calendar/Tables/Forms) * into the same CloudEvents `event` OR-object shape diff --git a/openspec/changes/absorb-dossiq-deliveries/design.md b/openspec/changes/absorb-dossiq-deliveries/design.md new file mode 100644 index 000000000..80172c2a0 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/design.md @@ -0,0 +1,47 @@ +# Design — absorb-dossiq-deliveries + +## Landing zone: the CloudEvents pipeline, not a new engine + +The integriq audit ranked four landing zones for a sibling app's delivery: a Flow node, an +`event_subscription` action, a bespoke provider quintet, and raw `CallService`. The seam lands on +the **event pipeline** because the requesting context is a backend transition handler (no user +session for the sibling-push controllers, no admin-authored flow at the dispatch site), and because +the pipeline already owns exactly the semantics a delivery needs: per-subscription retry policy, +exponential backoff, dead-letter + replay UI, HMAC signing, and status bookkeeping on +`event_message`. A flow can still do the actual transport — `action.kind = 'flow'` on the matching +subscription — so the seam composes with the wave-3 direction instead of competing with it. + +The legacy runners (`SynchronizationService`, `RuleService`, `JobService`, `FlowRunnerService`) are +never called directly by the seam; they are reachable only as subscription actions that already +existed. + +## The provenance gate + +`ingestDeliveryRequest()` writes the request's provenance into the event's `data.delivery` block. +`createEventMessage()` embeds the event's serialization in `event_message.payload`, so the terminal +hooks (`recordDeliverySuccess`, terminal `recordFailure`) can read +`payload.data.delivery.{sourceApp,correlationId}` without a second lookup. That block is the gate: +present → dispatch `DeliveryConcludedEvent`; absent → ordinary CloudEvent traffic, no conclusion. +`recordConfigurationError` does not conclude — a config error is operator-fixable and replayable, +not terminal. + +## Result-slot honesty + +`setMatchedSubscriptions()` exists so "accepted" and "will actually travel" are distinguishable. +Zero matches means the instance has no route for this delivery — the consumer records `unrouted` +and an operator configures a subscription; nothing pretends to deliver. This is the +fail-closed-refusal shape the fleet ruling requires. + +## Constructor compatibility + +`IEventDispatcher` joins the constructor as a nullable, defaulted final parameter — the same +pattern `ExecutionTraceService` used — so every pre-existing positional test instantiation keeps +working and DI supplies the real dispatcher in production. A null dispatcher simply skips +conclusions (unit-test contexts); the listener half is unaffected. + +## Replay semantics + +`replayMessage()` can revive an abandoned message. If the replay succeeds, a second conclusion +(`delivered`) is dispatched and supersedes the earlier `abandoned` at the consumer — consumers +MUST project last-terminal-state-wins (dossiq's listener does). This is deliberate: the message +record and the consumer's projection converge without a tombstone protocol. diff --git a/openspec/changes/absorb-dossiq-deliveries/proposal.md b/openspec/changes/absorb-dossiq-deliveries/proposal.md new file mode 100644 index 000000000..6e3f36b8d --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/proposal.md @@ -0,0 +1,74 @@ +# Proposal: absorb-dossiq-deliveries + +kind: capability — cites **ADR-041** (hydra org-wide: cross-app commands via typed events), +**ADR-013** (event-bus model) and the `events-cloudevents` spec. Coupled to the dossiq change +`dossiq-delivers-nothing`, which ships the requesting half. Train order: this PR merges first — it +defines the event contract dossiq's `class_exists()`-guarded dispatch resolves; dossiq's half fails +closed until then, so no ordering breakage either way. + +## Summary + +Fleet ruling: **case apps keep no delivery code — integrations belong to integriq.** This change +gives integriq the receiving half of the ADR-041 delivery seam so a sibling app (dossiq first) can +hand over an outbound delivery and get an honest, terminal answer back: + +1. **`OCA\Integriq\Event\DeliveryRequestedEvent`** — the typed cross-app command ("deliver this + payload on my behalf"), carrying provenance (`sourceApp`, subject register/schema/id/label), + `deliveryKind`, `channel`, a caller-composed payload, a `correlationId`, and a synchronous + result slot (`isHandled` / `getResultId` / `getMatchedSubscriptions`). +2. **`DeliveryRequestedListener`** — ingests the request into the existing CloudEvents pipeline as + a `nl.conduction.delivery.requested` event whose `data.delivery` block carries the provenance; + admin-configured `event_subscription`s route it to a webhook / flow / synchronization / + notificaties action and inherit retry, backoff, dead-letter, replay and HMAC signing unchanged. + Zero matched subscriptions is reported honestly so the consumer fail-closes as "unrouted". +3. **`OCA\Integriq\Event\DeliveryConcludedEvent`** — dispatched from the `event_message` state + machine when a provenance-carrying delivery reaches a terminal state: `delivered` on success, + `abandoned` when the retry budget is spent. Ordinary CloudEvent traffic (no provenance block) + never produces one. The consumer projects the outcome onto its own domain record (dossiq: the + case's publication entry). + +No new transport, no new engine: the seam is a thin typed-event skin over `EventService`, and it +deliberately does NOT touch the wave-3 retirement targets (`SynchronizationService`, `RuleService`, +`JobService`, `FlowRunnerService` are not called directly — a flow can still be the *subscription's +action*). + +## Why + +ADR-041 requires cross-app commands to travel as typed events defined by the target app; integriq +had no such contract (no ADR-041 recipe existed in this repo before this change). Meanwhile every +delivery-shaped surface dossiq carries is either unreachable, mocked, or retry-less, and integriq +already operates the machinery all of them need. The seam lets sibling apps shed transport without +integriq growing bespoke per-app code: one contract, provenance-routed subscriptions. + +The sibling-push controllers (`stufZkn#outbound`, `iwmoIjw#createMessage`, ...) stay: they serve +session-carrying frontend calls. The event seam serves backend/flow contexts where a server-side +HTTP call would 401 (the exact phantom ADR-041 documents). + +## What + +1. `lib/Event/DeliveryRequestedEvent.php` + `lib/Event/DeliveryConcludedEvent.php` (new). +2. `lib/EventListener/DeliveryRequestedListener.php` (new), registered in `Application::boot()`. +3. `EventService::ingestDeliveryRequest()` (new public method): persists the provenance-carrying + `event` object, fans out via `processEvent()`, returns event + created messages. +4. `EventService` terminal-state hooks: `recordDeliverySuccess()` and the terminal branch of + `recordFailure()` dispatch `DeliveryConcludedEvent` via a new nullable `IEventDispatcher` + constructor dependency (nullable + defaulted, same test-compatibility pattern as + `ExecutionTraceService`). Dispatch failures are logged and swallowed — the message record stays + the source of truth. +5. Unit tests: `EventServiceDeliverySeamTest` (ingest shape, delivered/abandoned dispatch, no + dispatch without provenance or on non-terminal failure), `DeliveryRequestedListenerTest` + (result-slot write-back, unhandled-on-ingest-failure, foreign-event ignore). + +## Follow-ups staged in tasks.md + +Phase 2 tracks the integriq-side halves of dossiq's staged extractions: StUF endpoint/credential +migration intake, a per-callback notificaties routing decision, and (on commission) real +Berichtenbox / DROP-LVBB transports as provider quintets. Each carries its blocker honestly. + +## Non-goals + +- A delivery-specific message schema: `event_message` + the CloudEvent `data.delivery` block + already carry everything the seam needs (the `*_message` quintet pattern stays reserved for + bespoke wire protocols with their own inbound leg). +- Replay semantics changes: a replayed abandoned message that later succeeds simply dispatches a + second, superseding `delivered` conclusion — consumers project last-terminal-state-wins. diff --git a/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md b/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md new file mode 100644 index 000000000..e3ed41897 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md @@ -0,0 +1,86 @@ +# delivery-intake Specification + +**Status:** proposed +**Scope:** integriq +**Tier:** V1 +**Depends on:** `events-cloudevents` spec (the `event`/`event_subscription`/`event_message` +pipeline this seam rides), Nextcloud `OCP\EventDispatcher\IEventDispatcher`. + +## Purpose + +The ADR-041 cross-app delivery seam: a sibling Conduction app composes WHAT must be delivered and +raises a typed event; integriq owns HOW it travels by fanning the request out through its +CloudEvents pipeline, and answers with a terminal conclusion the consumer projects onto its own +domain record. + +@e2e exclude The seam is a backend-only in-process typed-event exchange with no integriq browser +surface of its own: requests and conclusions surface in the existing Events / DeadLetters pages, +which have their own coverage. The seam behaviours are proven by the PHPUnit suites +(EventServiceDeliverySeamTest, DeliveryRequestedListenerTest) on this side and dossiq's +PublicationServiceTest / DeliveryConcludedListenerTest on the consumer side. + +## ADDED Requirements + +### Requirement: A delivery request is a typed event with a synchronous result slot + +Integriq SHALL expose `OCA\Integriq\Event\DeliveryRequestedEvent` carrying provenance +(`sourceApp`, `subjectRegister`, `subjectSchema`, `subjectId`, `subjectLabel`), a `deliveryKind`, +a `channel`, a caller-composed `payload`, a `correlationId`, and optional `externalReference` / +`userId`. The in-process listener SHALL write the result slot: `setHandled(true)`, the persisted +CloudEvent uuid via `setResultId()`, and the matched-subscription count via +`setMatchedSubscriptions()`. On ingest failure the event SHALL stay unhandled so the consumer +fail-closes. + +#### Scenario: A handled request carries the result slot + +- **GIVEN** the CloudEvents pipeline persists the request and one subscription matches +- **WHEN** the listener handles a `DeliveryRequestedEvent` +- **THEN** `isHandled()` MUST be true, `getResultId()` MUST be the event uuid, and + `getMatchedSubscriptions()` MUST be 1 + +#### Scenario: An ingest failure leaves the request unhandled + +- **WHEN** persisting or fanning out the request throws +- **THEN** the event MUST stay unhandled and MUST carry no result id + +### Requirement: Delivery requests ride the CloudEvents pipeline unchanged + +The listener SHALL persist the request as an `event` object of type +`nl.conduction.delivery.requested` with source `/apps//delivery`, the subject id as the +CloudEvents subject, and a `data.delivery` block carrying the full provenance, then fan it out via +`processEvent()`. Routing, retry, backoff, dead-letter, replay and HMAC signing SHALL be the +existing `event_subscription` / `event_message` machinery — no delivery-specific engine, and no +direct call into the legacy synchronization/rule/job runners. + +#### Scenario: The persisted event carries provenance + +- **WHEN** a request from `dossiq` for channel `gemeenteblad` is ingested +- **THEN** the persisted event MUST have type `nl.conduction.delivery.requested`, source + `/apps/dossiq/delivery`, and `data.delivery.sourceApp = 'dossiq'` with the correlation id + +### Requirement: A provenance-carrying delivery concludes with a typed terminal event + +When an `event_message` whose originating event carries a `data.delivery` provenance block reaches +a terminal state, integriq SHALL dispatch `OCA\Integriq\Event\DeliveryConcludedEvent` — +`delivered` from the success path, `abandoned` when the retry budget is spent — echoing +`sourceApp`, `correlationId`, `subjectId` and `channel`, with the attempt count, the last error (or +null) and the terminal timestamp. Ordinary CloudEvent traffic without the provenance block SHALL +never produce a conclusion, a non-terminal failure SHALL not conclude, and a conclusion-dispatch +failure SHALL be logged and swallowed — the message record stays the source of truth. + +#### Scenario: Success concludes delivered + +- **GIVEN** a pending message whose event data carries `delivery.sourceApp` and `correlationId` +- **WHEN** delivery succeeds +- **THEN** a `DeliveryConcludedEvent` with status `delivered` and the echoed correlation id MUST be + dispatched + +#### Scenario: A spent retry budget concludes abandoned + +- **WHEN** a provenance-carrying message fails with no retries remaining +- **THEN** a `DeliveryConcludedEvent` with status `abandoned` and the last error MUST be dispatched + +#### Scenario: Ordinary traffic never concludes + +- **WHEN** a message without a `data.delivery` provenance block reaches any terminal state +- **THEN** no `DeliveryConcludedEvent` is dispatched diff --git a/openspec/changes/absorb-dossiq-deliveries/tasks.md b/openspec/changes/absorb-dossiq-deliveries/tasks.md new file mode 100644 index 000000000..58a6fcf46 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/tasks.md @@ -0,0 +1,42 @@ +# Tasks — absorb dossiq deliveries: the ADR-041 delivery seam + +## Phase 1: The delivery seam (this PR) + +- [x] `lib/Event/DeliveryRequestedEvent.php` — provenance + payload + synchronous result slot + (`setHandled`/`isHandled`, `setResultId`/`getResultId`, `setMatchedSubscriptions`). +- [x] `lib/Event/DeliveryConcludedEvent.php` — terminal outcome envelope (`delivered` / + `abandoned`, attempts, error, concludedAt), echoing sourceApp + correlationId + subject. +- [x] `lib/EventListener/DeliveryRequestedListener.php` — ingest via + `EventService::ingestDeliveryRequest()`, write the result slot; leave unhandled on ingest + failure so the consumer fail-closes. +- [x] `EventService::ingestDeliveryRequest()` — persist the `nl.conduction.delivery.requested` + CloudEvent with the `data.delivery` provenance block, fan out via `processEvent()`. +- [x] `EventService::dispatchDeliveryConcluded()` — dispatched from `recordDeliverySuccess()` and + the terminal (`abandoned`) branch of `recordFailure()`, gated to provenance-carrying + messages; new nullable `IEventDispatcher` constructor dependency. +- [x] Register the listener in `Application::boot()`. +- [x] Unit tests: ingest event shape, delivered dispatch, abandoned dispatch with error, + no dispatch on non-terminal failure, no dispatch without provenance, listener result-slot + write-back, listener unhandled-on-failure, foreign-event ignore. + +## Phase 2: Intake halves of dossiq's staged extractions — staged + +- [ ] **StUF endpoint/credential migration intake.** Blocked on: migration design — dossiq's + `stufEndpoint` objects hold `vault://` refs resolved via dossiq `IAppConfig`; integriq + sources resolve through the OpenRegister credential broker. Needs a documented mapping + (dossiq repair step writes `source` objects `type=stuf-zkn` with broker refs; secrets are + re-entered or brokered, never copied blind). Tracked jointly with dossiq + `dossiq-delivers-nothing` phase 2. +- [ ] **Per-callback ZGW notificaties routing.** Blocked on: a design decision — dossiq's + notificaties fan-out is per-abonnement callback URLs; the seam carries one delivery request, + while subscriptions are admin-configured. Either the `notificaties` action kind gains + callback-from-payload support, or dossiq raises one request per callback. Decide before + dossiq phase 3 lands. +- [ ] **Berichtenbox (MijnOverheid) transport.** Blocked on: commissioning — no production + transport exists anywhere in the fleet (dossiq ships only a MockAdapter). When built, it is + an integriq provider quintet (controller + provider seam + sync service + `*_message` schema + + retry job, the StufZkn/IwmoIjw pattern) addressed by `deliveryKind: 'berichtenbox'`. +- [ ] **DROP/LVBB publication transport.** Blocked on: commissioning — no DROP/LVBB transport + exists in dossiq to move (its PublicationService was record-only); a real + bekendmaking-via-DROP delivery is new integriq work, addressed by the existing + `deliveryKind: 'besluit-publication'` routing on channel `gemeenteblad`. diff --git a/tests/Unit/EventListener/DeliveryRequestedListenerTest.php b/tests/Unit/EventListener/DeliveryRequestedListenerTest.php new file mode 100644 index 000000000..030f9966e --- /dev/null +++ b/tests/Unit/EventListener/DeliveryRequestedListenerTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\EventListener; + +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\EventListener\DeliveryRequestedListener; +use OCA\Integriq\Service\EventService; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for DeliveryRequestedListener. + * + * @covers \OCA\Integriq\EventListener\DeliveryRequestedListener + * @covers \OCA\Integriq\Event\DeliveryRequestedEvent + * + * @uses \OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder + */ +class DeliveryRequestedListenerTest extends TestCase { + + /** + * The mocked EventService. + * + * @var EventService|\PHPUnit\Framework\MockObject\MockObject + */ + private $eventService; + + /** + * The listener under test. + * + * @var DeliveryRequestedListener + */ + private DeliveryRequestedListener $listener; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->eventService = $this->getMockBuilder(EventService::class) + ->disableOriginalConstructor() + ->getMock(); + $this->listener = new DeliveryRequestedListener( + eventService: $this->eventService, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + /** + * Build a delivery request event. + * + * @return DeliveryRequestedEvent + */ + private function request(): DeliveryRequestedEvent { + return new DeliveryRequestedEvent( + sourceApp: 'dossiq', + subjectRegister: 'dossiq', + subjectSchema: 'case', + subjectId: 'case-1', + subjectLabel: 'Kapvergunning', + deliveryKind: 'besluit-publication', + channel: 'gemeenteblad', + payload: ['caseId' => 'case-1'], + correlationId: 'corr-1', + ); + }//end request() + + /** + * A successful ingest writes handled + resultId + matched count back onto + * the event. + * + * @return void + */ + public function testHandledRequestCarriesResultSlot(): void { + $eventEntity = ObjectServiceMockBuilder::objectEntity($this, ['type' => 'nl.conduction.delivery.requested'], 'evt-1'); + $message = ObjectServiceMockBuilder::objectEntity($this, ['status' => 'pending'], 'msg-1'); + $this->eventService->method('ingestDeliveryRequest')->willReturn( + [ + 'event' => $eventEntity, + 'messages' => [$message], + ] + ); + + $event = $this->request(); + $this->listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertSame('evt-1', $event->getResultId()); + $this->assertSame(1, $event->getMatchedSubscriptions()); + }//end testHandledRequestCarriesResultSlot() + + /** + * An ingest failure leaves the event unhandled — the consumer's + * fail-closed guard then records the refusal. + * + * @return void + */ + public function testIngestFailureLeavesEventUnhandled(): void { + $this->eventService->method('ingestDeliveryRequest') + ->willThrowException(new \RuntimeException('register unavailable')); + + $event = $this->request(); + $this->listener->handle($event); + + $this->assertFalse($event->isHandled()); + $this->assertNull($event->getResultId()); + }//end testIngestFailureLeavesEventUnhandled() + + /** + * A non-delivery event is ignored. + * + * @return void + */ + public function testIgnoresForeignEvents(): void { + $this->eventService->expects($this->never())->method('ingestDeliveryRequest'); + $this->listener->handle(new class extends Event { + }); + }//end testIgnoresForeignEvents() +}//end class diff --git a/tests/Unit/Service/EventServiceDeliverySeamTest.php b/tests/Unit/Service/EventServiceDeliverySeamTest.php new file mode 100644 index 000000000..78a44dee1 --- /dev/null +++ b/tests/Unit/Service/EventServiceDeliverySeamTest.php @@ -0,0 +1,284 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\Service; + +use OCA\Integriq\Event\DeliveryConcludedEvent; +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\Service\CallService; +use OCA\Integriq\Service\EventService; +use OCA\Integriq\Service\FlowRunnerService; +use OCA\Integriq\Service\JobService; +use OCA\Integriq\Service\SynchronizationService; +use OCA\Integriq\Service\WebhookSignatureService; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClientService; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for the EventService delivery seam. + * + * @covers \OCA\Integriq\Service\EventService + * @covers \OCA\Integriq\Event\DeliveryRequestedEvent + * @covers \OCA\Integriq\Event\DeliveryConcludedEvent + * + * @uses \OCA\Integriq\Service\WebhookSignatureService + * @uses \OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder + */ +class EventServiceDeliverySeamTest extends TestCase { + + /** + * The mocked OR ObjectService. + * + * @var \OCA\OpenRegister\Service\ObjectService|\PHPUnit\Framework\MockObject\MockObject + */ + private $objectService; + + /** + * The mocked event dispatcher. + * + * @var IEventDispatcher|\PHPUnit\Framework\MockObject\MockObject + */ + private $eventDispatcher; + + /** + * The service under test. + * + * @var EventService + */ + private EventService $service; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = ObjectServiceMockBuilder::make($this); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $logger = $this->createMock(LoggerInterface::class); + + $this->service = new EventService( + $this->objectService, + $this->createMock(IClientService::class), + $logger, + new WebhookSignatureService($logger), + $this->createMock(SynchronizationService::class), + $this->createMock(JobService::class), + $this->createMock(CallService::class), + $this->createMock(FlowRunnerService::class), + null, + null, + null, + null, + $this->eventDispatcher, + ); + }//end setUp() + + /** + * Build the persisted data of a provenance-carrying event_message. + * + * @param int $attempts How many attempts the message carries. + * @param string|null $error The last error on the message. + * + * @return array + */ + private function provenanceMessageData(int $attempts = 1, ?string $error = null): array { + $attemptRows = []; + for ($i = 0; $i < $attempts; $i++) { + $attemptRows[] = ['at' => '2026-09-02T12:00:0' . $i . '+00:00', 'statusCode' => null, 'error' => null]; + } + + return [ + 'event' => 'evt-1', + 'subscription' => 'sub-1', + 'status' => 'pending', + 'attempts' => $attemptRows, + 'error' => $error, + 'payload' => [ + 'data' => [ + 'delivery' => [ + 'sourceApp' => 'dossiq', + 'subjectId' => 'case-1', + 'channel' => 'gemeenteblad', + 'correlationId' => 'corr-1', + ], + 'payload' => ['caseId' => 'case-1'], + ], + ], + ]; + }//end provenanceMessageData() + + /** + * Ingesting a delivery request persists a provenance-carrying CloudEvent + * and fans it out through processEvent. + * + * @return void + */ + public function testIngestDeliveryRequestPersistsProvenanceEvent(): void { + $request = new DeliveryRequestedEvent( + sourceApp: 'dossiq', + subjectRegister: 'dossiq', + subjectSchema: 'case', + subjectId: 'case-1', + subjectLabel: 'Kapvergunning', + deliveryKind: 'besluit-publication', + channel: 'gemeenteblad', + payload: ['caseId' => 'case-1'], + correlationId: 'corr-1', + ); + + $savedObject = null; + $eventEntity = ObjectServiceMockBuilder::objectEntity($this, [], 'evt-1'); + $this->objectService->method('saveObject')->willReturnCallback( + static function (array $object) use (&$savedObject, $eventEntity) { + $savedObject = $object; + return $eventEntity; + } + ); + // No active subscriptions: processEvent matches nothing. + $this->objectService->method('findAll')->willReturn(['results' => []]); + + $result = $this->service->ingestDeliveryRequest(request: $request); + + $this->assertSame('evt-1', $result['event']->getUuid()); + $this->assertSame([], $result['messages']); + $this->assertNotNull($savedObject); + $this->assertSame(EventService::DELIVERY_REQUESTED_TYPE, $savedObject['type']); + $this->assertSame('/apps/dossiq/delivery', $savedObject['source']); + $this->assertSame('case-1', $savedObject['subject']); + $this->assertSame('dossiq', $savedObject['data']['delivery']['sourceApp']); + $this->assertSame('corr-1', $savedObject['data']['delivery']['correlationId']); + $this->assertSame(['caseId' => 'case-1'], $savedObject['data']['payload']); + }//end testIngestDeliveryRequestPersistsProvenanceEvent() + + /** + * A successful delivery of a provenance-carrying message dispatches the + * delivered conclusion. + * + * @return void + */ + public function testRecordDeliverySuccessDispatchesConcluded(): void { + $message = ObjectServiceMockBuilder::objectEntity($this, $this->provenanceMessageData(), 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $dispatched = null; + $this->eventDispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use (&$dispatched): void { + $dispatched = $event; + } + ); + + $method = new \ReflectionMethod(EventService::class, 'recordDeliverySuccess'); + $method->invoke($this->service, $message); + + $this->assertInstanceOf(DeliveryConcludedEvent::class, $dispatched); + $this->assertSame('dossiq', $dispatched->getSourceApp()); + $this->assertSame('corr-1', $dispatched->getCorrelationId()); + $this->assertSame('case-1', $dispatched->getSubjectId()); + $this->assertSame('gemeenteblad', $dispatched->getChannel()); + $this->assertSame(DeliveryConcludedEvent::STATUS_DELIVERED, $dispatched->getStatus()); + $this->assertSame('evt-1', $dispatched->getEventId()); + $this->assertSame('msg-1', $dispatched->getMessageId()); + // recordDeliverySuccess appends the successful attempt. + $this->assertSame(2, $dispatched->getAttempts()); + $this->assertNull($dispatched->getError()); + }//end testRecordDeliverySuccessDispatchesConcluded() + + /** + * Spending the retry budget dispatches the abandoned conclusion with the + * last error. + * + * @return void + */ + public function testRecordFailureTerminalDispatchesAbandoned(): void { + $data = $this->provenanceMessageData(); + $data['retryCount'] = 0; + $message = ObjectServiceMockBuilder::objectEntity($this, $data, 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $dispatched = null; + $this->eventDispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use (&$dispatched): void { + $dispatched = $event; + } + ); + + $method = new \ReflectionMethod(EventService::class, 'recordFailure'); + $method->invoke($this->service, $message, 'HTTP 503', 503, null, ['maxRetries' => 1]); + + $this->assertInstanceOf(DeliveryConcludedEvent::class, $dispatched); + $this->assertSame(DeliveryConcludedEvent::STATUS_ABANDONED, $dispatched->getStatus()); + $this->assertSame('HTTP 503', $dispatched->getError()); + $this->assertSame('corr-1', $dispatched->getCorrelationId()); + }//end testRecordFailureTerminalDispatchesAbandoned() + + /** + * A non-terminal failure (retry budget remaining) dispatches nothing. + * + * @return void + */ + public function testRecordFailureNonTerminalDispatchesNothing(): void { + $data = $this->provenanceMessageData(); + $data['retryCount'] = 0; + $message = ObjectServiceMockBuilder::objectEntity($this, $data, 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $method = new \ReflectionMethod(EventService::class, 'recordFailure'); + $method->invoke($this->service, $message, 'HTTP 503', 503, null, ['maxRetries' => 5]); + }//end testRecordFailureNonTerminalDispatchesNothing() + + /** + * Ordinary CloudEvent traffic — no provenance block — never produces a + * conclusion. + * + * @return void + */ + public function testNoConclusionWithoutProvenance(): void { + $message = ObjectServiceMockBuilder::objectEntity( + $this, + [ + 'event' => 'evt-2', + 'status' => 'pending', + 'attempts' => [], + 'payload' => ['data' => ['id' => 'obj-1']], + ], + 'msg-2' + ); + $this->objectService->method('saveObject')->willReturn($message); + + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $method = new \ReflectionMethod(EventService::class, 'recordDeliverySuccess'); + $method->invoke($this->service, $message); + }//end testNoConclusionWithoutProvenance() +}//end class diff --git a/tests/Unit/Service/EventServiceTest.php b/tests/Unit/Service/EventServiceTest.php index c91e5c62f..fa49b9471 100644 --- a/tests/Unit/Service/EventServiceTest.php +++ b/tests/Unit/Service/EventServiceTest.php @@ -588,7 +588,10 @@ public function testDeliverMessageSuccessMarksDelivered(): void { $this->assertSame(200, $captured['deliveryResponse']['statusCode']); $this->assertCount(1, $captured['attempts']); $this->assertSame(200, $captured['attempts'][0]['statusCode']); - $this->assertNull($captured['attempts'][0]['error']); + // appendAttempt() OMITS a null error rather than writing it — the + // schema types attempts[].error as string and OpenRegister refuses + // null for a nested array-item property (see appendAttempt()). + $this->assertArrayNotHasKey('error', $captured['attempts'][0]); }//end testDeliverMessageSuccessMarksDelivered() /** @@ -672,7 +675,10 @@ public function testDeliverMessageExceptionRecordsErrorAttempt(): void { $this->assertFalse($result); $this->assertSame('failed', $captured['status']); $this->assertCount(1, $captured['attempts']); - $this->assertNull($captured['attempts'][0]['statusCode']); + // A transport failure has no HTTP status by definition, and + // appendAttempt() OMITS the key rather than writing null — writing + // null used to fail schema validation and abort the retry sweep. + $this->assertArrayNotHasKey('statusCode', $captured['attempts'][0]); $this->assertNotNull($captured['attempts'][0]['error']); }//end testDeliverMessageExceptionRecordsErrorAttempt() From b97ac6ab8c19886f7faa5c509f2ed32854671655 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:06:38 +0200 Subject: [PATCH 17/22] feat(approvals): mirror every HITL approval into the shared OpenRegister task service (#1812) The adoption seam for the fleet task consolidation (wave 2, depends on openregister task-expiry-and-outcomes): every suspension creates one shared task through TaskService::import() carrying the approver group, requester, expiresAt and the record's onTimeout/onReject, linked back via taskUuid; approve and reject close the mirror with the matching outcome; the shared timer sweep owns the mirror's expiry. A mirror failure never gates the approval flow. Full retirement of the app-local machinery is tracked as follow-up in the openspec change. Co-authored-by: Conduction Release Bot --- lib/Service/ApprovalService.php | 171 ++++++++- .../changes/hitl-on-shared-tasks/design.md | 56 +++ .../changes/hitl-on-shared-tasks/proposal.md | 55 +++ .../specs/hitl-on-shared-tasks/spec.md | 61 +++ .../changes/hitl-on-shared-tasks/tasks.md | 25 ++ psalm.xml | 5 + .../Service/ApprovalServiceSharedTaskTest.php | 355 ++++++++++++++++++ tests/bootstrap.php | 11 + tests/stubs/OCA/OpenRegister/Db/Task.php | 56 +++ .../OpenRegister/Service/Task/TaskService.php | 62 +++ 10 files changed, 855 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/hitl-on-shared-tasks/design.md create mode 100644 openspec/changes/hitl-on-shared-tasks/proposal.md create mode 100644 openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md create mode 100644 openspec/changes/hitl-on-shared-tasks/tasks.md create mode 100644 tests/Unit/Service/ApprovalServiceSharedTaskTest.php create mode 100644 tests/stubs/OCA/OpenRegister/Db/Task.php create mode 100644 tests/stubs/OCA/OpenRegister/Service/Task/TaskService.php diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php index ed24743fb..da532acfa 100644 --- a/lib/Service/ApprovalService.php +++ b/lib/Service/ApprovalService.php @@ -42,6 +42,7 @@ use OCA\Integriq\Service\Helper\FlowToken; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\ObjectService as ORObjectService; +use OCA\OpenRegister\Service\Task\TaskService as ORTaskService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IGroupManager; use OCP\IURLGenerator; @@ -106,6 +107,11 @@ class ApprovalService { * @param ExecutionTraceService|null $executionTraceService Persists the traced run's execution_trace at * suspension/resume (execution-trace REQ-004). Nullable + defaulted so * pre-existing positional test instantiations keep working unmodified. + * @param ORTaskService|null $taskService OpenRegister's shared task service: every suspension mirrors ONE + * shared task through it and every decision closes that mirror + * (hitl-on-shared-tasks D-1). Nullable + defaulted for the same + * positional-test reason; absent, no mirror exists and the approval + * flow is unchanged. */ public function __construct( private readonly ORObjectService $objectService, @@ -115,6 +121,7 @@ public function __construct( private readonly IURLGenerator $urlGenerator, private readonly LoggerInterface $logger, private readonly ?ExecutionTraceService $executionTraceService = null, + private readonly ?ORTaskService $taskService = null, ) { }//end __construct() @@ -195,6 +202,7 @@ public function suspend(ObjectEntity $endpoint, ObjectEntity $rule, FlowToken $f } } + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -243,6 +251,7 @@ public function suspendForSynchronization( schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -296,6 +305,7 @@ public function suspendForFlow(ObjectEntity $flowRun, int $resumeStepOrder, arra schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -349,6 +359,7 @@ public function suspendForSubscription( schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -575,13 +586,16 @@ public function completeApproval( $data['comment'] = $comment; } - return $this->objectService->saveObject( + $saved = $this->objectService->saveObject( object: $data, register: self::REGISTER, schema: self::SCHEMA, uuid: $approvalRequest->getUuid() ); + $this->closeSharedTask(data: $data, outcome: 'transition:approved', actorUid: $approver->getUID()); + + return $saved; }//end completeApproval() /** @@ -618,13 +632,24 @@ public function reject(ObjectEntity $approvalRequest, IUser $approver, string $c $data['rejectedAt'] = (new DateTime())->format('c'); $data['comment'] = $comment; - return $this->objectService->saveObject( + $saved = $this->objectService->saveObject( object: $data, register: self::REGISTER, schema: self::SCHEMA, uuid: $approvalRequest->getUuid() ); + // The mirror ends the way the record did: dead-lettered when + // onReject routed the record there, plainly rejected otherwise + // (hitl-on-shared-tasks D-4). + $mirrorOutcome = 'transition:rejected'; + if ($data['status'] === 'dead_letter') { + $mirrorOutcome = 'dead_letter'; + } + + $this->closeSharedTask(data: $data, outcome: $mirrorOutcome, actorUid: $approver->getUID()); + + return $saved; }//end reject() /** @@ -791,6 +816,148 @@ public function notifyApprovers(ObjectEntity $approvalRequest): void { }//end notifyApprovers() + /** + * Mirror a just-created, pending approval_request into ONE shared + * OpenRegister task (hitl-on-shared-tasks D-1/D-2): approver group as + * candidate group, requester, expiry, and the record's + * onTimeout/onReject when they are in the shared vocabulary, so the + * shared timer sweep owns the mirror's expiry (D-3). The created task's + * uuid is written back onto the record as `taskUuid`. + * + * A failure here is logged and swallowed: the approval flow is the + * system of record and MUST NOT be gated by the mirror (D-5). + * + * @param ObjectEntity $approvalRequest The pending approval_request. + * + * @return ObjectEntity The record, carrying `taskUuid` when the mirror was created. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-every-suspension-mirrors-one-shared-task + */ + private function mirrorIntoSharedTask(ObjectEntity $approvalRequest): ObjectEntity { + if ($this->taskService === null) { + return $approvalRequest; + } + + $data = $approvalRequest->getObject(); + $actor = (string)($data['requesterUserId'] ?? ''); + if ($actor === '') { + $actor = 'integriq'; + } + + try { + $task = $this->taskService->import( + data: $this->sharedTaskData(data: $data, approvalRequestId: (string)$approvalRequest->getUuid()), + actor: $actor + ); + + $data['taskUuid'] = (string)$task->getUuid(); + + return $this->objectService->saveObject( + object: $data, + register: self::REGISTER, + schema: self::SCHEMA, + uuid: $approvalRequest->getUuid() + ); + } catch (Throwable $e) { + $this->logger->warning( + 'ApprovalService: could not mirror the approval into the shared task service: ' . $e->getMessage(), + ['approvalRequest' => $approvalRequest->getUuid()] + ); + + return $approvalRequest; + } + }//end mirrorIntoSharedTask() + + /** + * The shared-task payload a pending approval_request mirrors to. + * + * `onTimeout`/`onReject` travel only when they are in the shared + * vocabulary (`skip`|`error`|`dead_letter`); anything else stays an + * app-local behaviour and the mirror carries none. + * + * @param array $data The approval_request object data. + * @param string $approvalRequestId The record uuid the task links back to. + * + * @return array The task creation payload. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-every-suspension-mirrors-one-shared-task + */ + private function sharedTaskData(array $data, string $approvalRequestId): array { + $payload = [ + 'state' => 'enabled', + 'title' => 'Approval request', + 'description' => 'Approve or reject this request in Integriq. Your decision resumes the suspended run.', + 'performerType' => 'user', + 'appId' => 'integriq', + 'metadata' => [ + 'kind' => 'approval_request', + 'approvalRequestId' => $approvalRequestId, + ], + ]; + + if ((string)($data['approverGroup'] ?? '') !== '') { + $payload['candidateGroups'] = [(string)$data['approverGroup']]; + } + + if ((string)($data['requesterUserId'] ?? '') !== '') { + $payload['requester'] = (string)$data['requesterUserId']; + } + + if ((string)($data['expiresAt'] ?? '') !== '') { + $payload['expiresAt'] = (string)$data['expiresAt']; + $onTimeout = (string)($data['onTimeout'] ?? ''); + if (in_array($onTimeout, ['skip', 'error', 'dead_letter'], true) === true) { + $payload['onTimeout'] = $onTimeout; + } + } + + $onReject = (string)($data['onReject'] ?? ''); + if (in_array($onReject, ['skip', 'error', 'dead_letter'], true) === true) { + $payload['onReject'] = $onReject; + } + + return $payload; + }//end sharedTaskData() + + /** + * Close the mirrored shared task after a decision resolved the record + * (hitl-on-shared-tasks D-4), through the shared outcome path: the + * decision was already authorized by this service's own two-layer model, + * and the mirror has no assignee for a completion check to pass. + * + * A missing mirror (`taskUuid` absent: pre-seam rows, or a failed + * mirror) and a mirror already closed by the shared sweep are both + * fine; any failure is logged and swallowed (D-5). + * + * @param array $data The resolved approval_request object data. + * @param string $outcome The shared outcome (`transition:approved`, `transition:rejected` or `dead_letter`). + * @param string $actorUid The deciding user's uid, recorded as the source. + * + * @return void + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-a-decision-closes-the-mirrored-task + */ + private function closeSharedTask(array $data, string $outcome, string $actorUid): void { + $taskUuid = (string)($data['taskUuid'] ?? ''); + if ($this->taskService === null || $taskUuid === '') { + return; + } + + try { + $this->taskService->applyTimerOutcome( + uuid: $taskUuid, + outcome: $outcome, + source: 'integriq:' . $actorUid, + reason: sprintf("Approval request resolved as '%s'.", (string)($data['status'] ?? '')) + ); + } catch (Throwable $e) { + $this->logger->warning( + 'ApprovalService: could not close the mirrored shared task: ' . $e->getMessage(), + ['taskUuid' => $taskUuid] + ); + } + }//end closeSharedTask() + /** * Strip sensitive headers (at minimum `Authorization`) from a FlowToken * snapshot's request slots before persisting it — security-hard diff --git a/openspec/changes/hitl-on-shared-tasks/design.md b/openspec/changes/hitl-on-shared-tasks/design.md new file mode 100644 index 000000000..d6853e8f3 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/design.md @@ -0,0 +1,56 @@ +# Design: HITL approvals on the shared task service + +## D-1: a mirror, not a migration + +The approval_request record keeps owning suspend/resume orchestration; the +shared task mirrors its human-facing half (who must act, by when, with what +consequence). Four callers compose resume orchestration around +`ApprovalService` today; moving the record itself is follow-up work with its +own change. The mirror gives the fleet inbox, notification and expiry +machinery a real row NOW without touching any resume path. + +## D-2: the mirror is created on the trusted path + +`TaskService::import()` (in-process, trusted) rather than `create()`: the +mirror names a requester that is not the acting identity, and it is created +by a service, not over HTTP. The acting identity passed is the requester +when known, else the app id. + +## D-3: OpenRegister owns the mirror's expiry + +The mirrored task carries `expiresAt` and the record's `onTimeout` (when it +is one of `skip`/`error`/`dead_letter`), so the shared timer sweep closes it +with the declared behaviour. integriq's `ApprovalTimeoutSweepJob` keeps +resolving the approval_request itself. The two sweeps run at the same 300s +cadence and both are idempotent, so the pair converges without coordination: +the record ends `expired`/`dead_letter`, the task ends through its declared +behaviour. Retiring the app-local sweep for mirrored rows is follow-up 1. + +## D-4: decisions close the mirror through the outcome path + +`completeApproval()` closes the mirror with `transition:approved`; +`reject()` with `transition:rejected`, or `dead_letter` when the record's +`onReject` routed the record there. The outcome path +(`applyTimerOutcome()`) is chosen over `complete()` because the mirror has +no assignee: the decision was authorized by integriq's own two-layer model +(action matrix + approver group) before the close, and re-running the task +service's assignee check against a pooled mirror would refuse a decision +that already happened. The source names the deciding user +(`integriq:`). + +## D-5: the mirror never gates the approval flow + +Creation, linking and closing of the mirror are each wrapped: a failure is +logged as a warning and the approval flow proceeds. A missing `taskUuid` +(pre-seam rows, or a failed mirror) simply means no mirror to close. The +inverse guarantee is OpenRegister's: `applyTimerOutcome()` on an +already-terminal task returns it unchanged, so a decision racing the shared +sweep cannot double-close. + +## D-6: tests stub the real signatures + +integriq's suite runs without the OpenRegister app. The stubs added for +`OCA\OpenRegister\Service\Task\TaskService` and `OCA\OpenRegister\Db\Task` +copy the REAL signatures (`import(array $data, ?string $actor): Task`, +`applyTimerOutcome(string $uuid, string $outcome, string $source, string +$reason): Task`), because a fake that agrees with the caller cannot fail. diff --git a/openspec/changes/hitl-on-shared-tasks/proposal.md b/openspec/changes/hitl-on-shared-tasks/proposal.md new file mode 100644 index 000000000..32e36b47e --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/proposal.md @@ -0,0 +1,55 @@ +# HITL approvals on the shared task service + +## Why + +integriq carries its own human-in-the-loop machinery: `approval_request` +objects with `expiresAt`, `onTimeout` and `onReject`, a 300s +`ApprovalTimeoutSweepJob`, and an imperative approver notification. The +fleet now has one task service in OpenRegister, and wave 2 of the +consolidation moved exactly these semantics into it +(openregister `task-expiry-and-outcomes`): tasks declare `onTimeout` and +`onReject` in the same vocabulary, and the shared sweep enforces `expiresAt`. +Keeping a second copy here means two sweeps, two vocabularies and an +approval inbox nobody shares. + +## What changes + +This change is the ADOPTION SEAM, not the full retirement. The +`approval_request` record stays the system of record for suspend/resume +orchestration (FlowToken snapshots, resume ordering, consumption), because +that orchestration is composed by `EndpointService`, `FlowRunnerService`, +`SynchronizationService` and two controllers, and ripping it out in the same +PR that introduces the seam would be a half-delete. + +- Every `ApprovalService::suspend*()` also creates ONE shared task through + OpenRegister's `TaskService::import()`: candidate group, requester, + `expiresAt`, `onTimeout`, `onReject` and a metadata link to the + approval_request. The task uuid lands on the approval_request as + `taskUuid`. +- Expiry of the mirrored task is OWNED by OpenRegister's timer sweep: the + task declares `onTimeout`, so the shared machinery closes it. integriq's + own sweep keeps resolving the `approval_request` record. +- `completeApproval()` and `reject()` close the mirrored task with the + matching outcome (`approved`, `rejected`, or `dead_letter` when the + record's `onReject` said so), so the shared inbox never shows a decided + approval as open. +- A mirror failure never fails the approval flow: created, closed or + skipped, the approval_request behaviour is unchanged. + +## Follow-ups (tracked, not in this PR) + +1. Listen to OpenRegister's `TaskTransitionedEvent` for mirrored tasks and + resolve the approval_request from the task side, then retire + `ApprovalTimeoutSweepJob` for mirrored rows. +2. Drive approve/reject from the shared task inbox (task-first), reducing + `ApprovalsController` to the resume orchestration. +3. Translate the mirrored task's title and description. + +## Impact + +- Affected specs: hitl-on-shared-tasks (new delta), referencing + approval-workflow. +- Affected code: `lib/Service/ApprovalService.php`, test stubs for the + OpenRegister task service. +- Depends on: openregister `task-expiry-and-outcomes` (runtime only; tests + stub the shared service). diff --git a/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md b/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md new file mode 100644 index 000000000..2f52fd4b8 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md @@ -0,0 +1,61 @@ +# hitl-on-shared-tasks + +## ADDED Requirements + +### Requirement: Every suspension mirrors one shared task + +Each `approval_request` created by a suspension SHALL be mirrored by exactly +one OpenRegister task, created through the shared task service's trusted +path, carrying the approver group as candidate group, the requester, the +`expiresAt`, and the record's `onTimeout` and `onReject` when they are in +the shared vocabulary. The task uuid SHALL be stored on the approval_request +as `taskUuid`. A mirror failure SHALL NOT fail the suspension. + +#### Scenario: a suspension creates the linked mirror task + +- **GIVEN** an endpoint rule pipeline suspending on an approval rule +- **WHEN** the approval_request is persisted +- **THEN** a shared task is created with the approver group, expiry and behaviours, and the record carries its uuid +- @e2e exclude {cross-app persistence seam; covered by unit tests against the stubbed shared service} + +#### Scenario: a failing shared service does not block the suspension + +- **GIVEN** a shared task service that throws on import +- **WHEN** the pipeline suspends +- **THEN** the approval_request is created and pending, without a `taskUuid`, and the failure is logged +- @e2e exclude {fault injection on a peer app; covered by unit tests} + +### Requirement: A decision closes the mirrored task + +Approving SHALL close the mirrored task with the `approved` outcome; +rejecting SHALL close it with the `rejected` outcome, or the dead-letter +outcome when the record's `onReject` routed the record to `dead_letter`. A +missing or already-closed mirror SHALL NOT fail the decision. + +#### Scenario: an approval closes the mirror as approved + +- **GIVEN** a pending approval_request carrying a `taskUuid` +- **WHEN** an authorized approver approves it +- **THEN** the mirrored task is closed with outcome `approved`, attributed to the deciding user +- @e2e exclude {cross-app close seam; covered by unit tests against the stubbed shared service} + +#### Scenario: a dead-letter rejection routes the mirror the same way + +- **GIVEN** a pending approval_request with `onReject: dead_letter` and a `taskUuid` +- **WHEN** an authorized approver rejects it with a comment +- **THEN** the record and the mirrored task both end dead-lettered +- @e2e exclude {cross-app close seam; covered by unit tests} + +### Requirement: The shared sweep owns the mirror's expiry + +The mirrored task SHALL declare its expiry behaviour so OpenRegister's timer +sweep encloses it; integriq's own sweep SHALL keep resolving the +approval_request record and SHALL NOT gain a second enforcement path for the +mirror. + +#### Scenario: an expired approval converges on both sides + +- **GIVEN** a pending approval_request past its `expiresAt`, mirrored with `onTimeout` +- **WHEN** both 300s sweeps have run +- **THEN** the record is `expired` (or `dead_letter`) and the task was closed by the shared sweep with the declared behaviour +- @e2e exclude {two background sweeps across apps; each side is covered by its own unit tests} diff --git a/openspec/changes/hitl-on-shared-tasks/tasks.md b/openspec/changes/hitl-on-shared-tasks/tasks.md new file mode 100644 index 000000000..f824636c3 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/tasks.md @@ -0,0 +1,25 @@ +# Tasks: hitl-on-shared-tasks + +## 1. The seam (this PR) + +- [x] 1.1 `ApprovalService` gains the nullable shared task service and a + `mirrorIntoSharedTask()` called from all four suspend paths, linking + `taskUuid` onto the approval_request; failures logged, never thrown. +- [x] 1.2 `completeApproval()`/`reject()` close the mirror with the + matching outcome through the shared outcome path. +- [x] 1.3 Test stubs for `OCA\OpenRegister\Service\Task\TaskService` and + `OCA\OpenRegister\Db\Task` with the real signatures, registered in + the bootstrap. +- [x] 1.4 Unit tests: mirror created and linked, decision closes it, + failures never gate the approval flow. + +## 2. Follow-ups (tracked in the issue, NOT this PR) + +- [ ] 2.1 Listen to `TaskTransitionedEvent` for mirrored tasks; resolve the + approval_request task-first; retire `ApprovalTimeoutSweepJob` for + mirrored rows (keep it for pre-seam rows). +- [ ] 2.2 Drive approve/reject from the shared inbox; reduce + `ApprovalsController` to resume orchestration. +- [ ] 2.3 Delegate the approver notification to the shared task service and + drop the imperative dispatch in `notifyApprovers()`. +- [ ] 2.4 Translate the mirrored task's title and description. diff --git a/psalm.xml b/psalm.xml index 05a90ed54..0e66e022b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -77,6 +77,11 @@ + + + diff --git a/tests/Unit/Service/ApprovalServiceSharedTaskTest.php b/tests/Unit/Service/ApprovalServiceSharedTaskTest.php new file mode 100644 index 000000000..f07d2b71c --- /dev/null +++ b/tests/Unit/Service/ApprovalServiceSharedTaskTest.php @@ -0,0 +1,355 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\Service; + +use OCA\Integriq\Service\ApprovalService; +use OCA\Integriq\Service\Helper\FlowToken; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Task\TaskService as ORTaskService; +use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Notification\IManager as INotificationManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for the shared-task mirror seam. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md + */ +class ApprovalServiceSharedTaskTest extends TestCase { + + /** + * @var \PHPUnit\Framework\MockObject\MockObject + */ + private $objectService; + + /** + * @var ORTaskService|MockObject + */ + private $taskService; + + /** + * @var LoggerInterface|MockObject + */ + private $logger; + + /** + * @var ApprovalService + */ + private ApprovalService $service; + + /** + * Every saveObject call's payload, in order. + * + * @var array + */ + private array $saved = []; + + /** + * Set up fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = ObjectServiceMockBuilder::make($this); + $this->taskService = $this->createMock(ORTaskService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->saved = []; + $this->objectService->method('saveObject')->willReturnCallback( + function (array $object) { + $this->saved[] = $object; + $entity = new ObjectEntity(); + $entity->setUuid('approval-created'); + $entity->setObject($object); + + return $entity; + } + ); + + $userSession = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('rita'); + $userSession->method('getUser')->willReturn($user); + + $this->service = new ApprovalService( + $this->objectService, + $userSession, + $this->createMock(IGroupManager::class), + $this->createMock(INotificationManager::class), + $this->createMock(IURLGenerator::class), + $this->logger, + null, + $this->taskService, + ); + + }//end setUp() + + /** + * A shared task entity carrying a uuid, via the real Entity accessors. + * + * @param string $uuid The task uuid. + * + * @return Task + */ + private function task(string $uuid): Task { + $task = new Task(); + $task->setUuid($uuid); + + return $task; + }//end task() + + /** + * A resolved-enough approval_request entity. + * + * @param array $body The object data. + * + * @return ObjectEntity + */ + private function entity(array $body): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid('approval-1'); + $entity->setObject($body); + + return $entity; + }//end entity() + + /** + * The deciding user. + * + * @return IUser|MockObject + */ + private function approver() { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + return $user; + }//end approver() + + /** + * A suspension creates ONE shared task through the trusted path, + * carrying group, expiry, behaviours and the record link, and writes + * the task uuid back onto the record. + * + * @return void + */ + public function testSuspendForSynchronizationMirrorsOneLinkedSharedTask(): void { + $imported = null; + $this->taskService->expects($this->once())->method('import')->willReturnCallback( + function (array $data, ?string $actor) use (&$imported): Task { + $imported = ['data' => $data, 'actor' => $actor]; + + return $this->task('task-9'); + } + ); + + $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'error', + onTimeout: 'dead_letter', + ttlSeconds: 3600, + ); + + $this->assertSame(['woo-approvers'], $imported['data']['candidateGroups']); + $this->assertSame('dead_letter', $imported['data']['onTimeout']); + $this->assertSame('error', $imported['data']['onReject']); + $this->assertNotEmpty($imported['data']['expiresAt']); + $this->assertSame('rita', $imported['data']['requester']); + $this->assertSame('rita', $imported['actor']); + $this->assertSame('approval-created', $imported['data']['metadata']['approvalRequestId']); + $this->assertSame('integriq', $imported['data']['appId']); + + // Two record writes: the pending create, then the taskUuid link. + $this->assertCount(2, $this->saved); + $this->assertArrayNotHasKey('taskUuid', $this->saved[0]); + $this->assertSame('task-9', $this->saved[1]['taskUuid']); + + }//end testSuspendForSynchronizationMirrorsOneLinkedSharedTask() + + /** + * A behaviour outside the shared vocabulary is NOT forwarded: the + * mirror carries no behaviour rather than a refused word. + * + * @return void + */ + public function testAnUnknownBehaviourStaysAppLocal(): void { + $imported = null; + $this->taskService->method('import')->willReturnCallback( + function (array $data, ?string $actor) use (&$imported): Task { + $imported = $data; + + return $this->task('task-9'); + } + ); + + $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'explode', + onTimeout: 'explode', + ttlSeconds: 60, + ); + + $this->assertArrayNotHasKey('onTimeout', $imported); + $this->assertArrayNotHasKey('onReject', $imported); + + }//end testAnUnknownBehaviourStaysAppLocal() + + /** + * A failing shared service never fails the suspension: the record is + * created pending, without a taskUuid, and the failure is logged. + * + * @return void + */ + public function testAMirrorFailureNeverGatesTheSuspension(): void { + $this->taskService->method('import')->willThrowException(new RuntimeException('peer app down')); + $warnings = []; + $this->logger->method('warning')->willReturnCallback( + static function (string $message) use (&$warnings): void { + $warnings[] = $message; + } + ); + + $record = $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'error', + onTimeout: 'error', + ttlSeconds: 60, + ); + + $this->assertSame('pending', $record->getObject()['status']); + $this->assertCount(1, $this->saved, 'no link write happened'); + $this->assertArrayNotHasKey('taskUuid', $this->saved[0]); + $this->assertNotEmpty(array_filter($warnings, static fn (string $m): bool => str_contains($m, 'could not mirror'))); + + }//end testAMirrorFailureNeverGatesTheSuspension() + + /** + * An approval closes the mirror as approved, attributed to the + * deciding user. + * + * @return void + */ + public function testAnApprovalClosesTheMirrorAsApproved(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('transition:approved'), + $this->equalTo('integriq:alice'), + $this->stringContains('approved') + ) + ->willReturn($this->task('task-9')); + + $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending', 'taskUuid' => 'task-9']), + approver: $this->approver(), + resumeResult: 'success', + ); + + }//end testAnApprovalClosesTheMirrorAsApproved() + + /** + * A dead-letter rejection routes the mirror the same way the record + * went. + * + * @return void + */ + public function testADeadLetterRejectionDeadLettersTheMirror(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('dead_letter'), + $this->equalTo('integriq:alice'), + $this->anything() + ) + ->willReturn($this->task('task-9')); + + $this->service->reject( + approvalRequest: $this->entity(['status' => 'pending', 'onReject' => 'dead_letter', 'taskUuid' => 'task-9']), + approver: $this->approver(), + comment: 'niet akkoord', + ); + + }//end testADeadLetterRejectionDeadLettersTheMirror() + + /** + * A plain rejection closes the mirror as rejected. + * + * @return void + */ + public function testAPlainRejectionClosesTheMirrorAsRejected(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('transition:rejected'), + $this->anything(), + $this->anything() + ) + ->willReturn($this->task('task-9')); + + $this->service->reject( + approvalRequest: $this->entity(['status' => 'pending', 'onReject' => 'error', 'taskUuid' => 'task-9']), + approver: $this->approver(), + comment: 'nee', + ); + + }//end testAPlainRejectionClosesTheMirrorAsRejected() + + /** + * A record without a mirror (pre-seam, or a failed mirror) decides + * without touching the shared service, and a failing close is + * swallowed. + * + * @return void + */ + public function testAMissingOrFailingMirrorNeverGatesTheDecision(): void { + $this->taskService->expects($this->never())->method('applyTimerOutcome'); + $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending']), + approver: $this->approver(), + resumeResult: 'success', + ); + + $this->setUp(); + $this->taskService->method('applyTimerOutcome')->willThrowException(new RuntimeException('gone')); + $this->logger->expects($this->once())->method('warning'); + $saved = $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending', 'taskUuid' => 'task-9']), + approver: $this->approver(), + resumeResult: 'success', + ); + $this->assertSame('approved', $saved->getObject()['status']); + + }//end testAMissingOrFailingMirrorNeverGatesTheDecision() +}//end class diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c962d0b2..6b9756abe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -122,6 +122,17 @@ require_once $stubsDir . '/OCA/OpenRegister/Db/ObjectEntity.php'; } + // The shared task entity + service the HITL mirror writes through + // (hitl-on-shared-tasks). The entity must load before the service: + // the service's signatures reference it. + if (class_exists('OCA\\OpenRegister\\Db\\Task') === false) { + require_once $stubsDir . '/OCA/OpenRegister/Db/Task.php'; + } + + if (class_exists('OCA\\OpenRegister\\Service\\Task\\TaskService') === false) { + require_once $stubsDir . '/OCA/OpenRegister/Service/Task/TaskService.php'; + } + if (class_exists('OCA\\OpenRegister\\Service\\ObjectService') === false) { require_once $stubsDir . '/OCA/OpenRegister/Service/ObjectService.php'; } diff --git a/tests/stubs/OCA/OpenRegister/Db/Task.php b/tests/stubs/OCA/OpenRegister/Db/Task.php new file mode 100644 index 000000000..610d7810f --- /dev/null +++ b/tests/stubs/OCA/OpenRegister/Db/Task.php @@ -0,0 +1,56 @@ + $data The task fields. + * @param string|null $actor The creating identity. + * + * @return Task An empty task entity. + */ + public function import(array $data, ?string $actor): Task { + return new Task(); + }//end import() + + /** + * Apply a declared outcome to a task (real: idempotent on terminal rows). + * + * @param string $uuid The task uuid. + * @param string $outcome The declared outcome. + * @param string $source The applying source. + * @param string $reason The audited reason. + * + * @return Task An empty task entity. + */ + public function applyTimerOutcome(string $uuid, string $outcome, string $source, string $reason): Task { + return new Task(); + }//end applyTimerOutcome() +} From e9b2587bb59e5da94810ffb456097edf07bd639d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:34:02 +0200 Subject: [PATCH 18/22] docs(openspec): re-scope the three fat umbrellas onto the one engine (#1815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit visual-flow-orchestration, nextcloud-event-hub and hitl-approval-rule-action sat at 0/55, 0/43 and 0/42 while identical archived twins (2026-07-15) hold the same task lists checked with evidence and the code sits at HEAD: the wave-3 spec commit recreated changes that shipped the same evening. Each is now a superseded redirect with a disposition table, retired in place so no path breaks and no @spec anchor dangles (hitl keeps design.md + specs/, event-hub keeps specs/ — 70 live tags point there). Successors: flow scope routes to integriq-flow-nodes, retire-integriq-flow-schema and flow-native-synchronization; approval expiry rides hitl-on-shared-tasks (OR TaskService); the honest residue becomes approvals-verification-pack and nextcloud-event-hub-verification (both agent-ready), plus nc-events-start-or-flows to point NC events at OR flows instead of a fourth app-local action surface. --- .../.openspec.yaml | 2 + .../approvals-verification-pack/proposal.md | 79 ++++ .../approvals-verification-pack/tasks.md | 69 +++ .../context-brief.md | 25 - .../hitl-approval-rule-action/contract.md | 45 -- .../hitl-approval-rule-action/discovery.md | 124 ----- .../hitl-approval-rule-action/migration.md | 85 ---- .../hitl-approval-rule-action/proposal.md | 258 +++-------- .../hitl-approval-rule-action/tasks.md | 165 +------ .../hitl-approval-rule-action/test-plan.md | 223 --------- .../nc-events-start-or-flows/.openspec.yaml | 2 + .../nc-events-start-or-flows/proposal.md | 91 ++++ .../specs/nextcloud-event-triggers/spec.md | 39 ++ .../changes/nc-events-start-or-flows/tasks.md | 52 +++ .../.openspec.yaml | 2 + .../proposal.md | 82 ++++ .../nextcloud-event-hub-verification/tasks.md | 59 +++ .../nextcloud-event-hub/context-brief.md | 19 - .../changes/nextcloud-event-hub/design.md | 308 ------------- .../changes/nextcloud-event-hub/discovery.md | 107 ----- .../changes/nextcloud-event-hub/migration.md | 63 --- .../changes/nextcloud-event-hub/proposal.md | 205 +++------ openspec/changes/nextcloud-event-hub/tasks.md | 241 +--------- .../changes/nextcloud-event-hub/test-plan.md | 276 ----------- .../visual-flow-orchestration/design.md | 351 -------------- .../visual-flow-orchestration/proposal.md | 300 +++--------- .../specs/flow-orchestration/spec.md | 428 ------------------ .../specs/job-management/spec.md | 67 --- .../specs/rule-pipeline/spec.md | 72 --- .../visual-flow-orchestration/tasks.md | 222 +-------- .../visual-flow-orchestration/test-plan.md | 234 ---------- 31 files changed, 667 insertions(+), 3628 deletions(-) create mode 100644 openspec/changes/approvals-verification-pack/.openspec.yaml create mode 100644 openspec/changes/approvals-verification-pack/proposal.md create mode 100644 openspec/changes/approvals-verification-pack/tasks.md delete mode 100644 openspec/changes/hitl-approval-rule-action/context-brief.md delete mode 100644 openspec/changes/hitl-approval-rule-action/contract.md delete mode 100644 openspec/changes/hitl-approval-rule-action/discovery.md delete mode 100644 openspec/changes/hitl-approval-rule-action/migration.md delete mode 100644 openspec/changes/hitl-approval-rule-action/test-plan.md create mode 100644 openspec/changes/nc-events-start-or-flows/.openspec.yaml create mode 100644 openspec/changes/nc-events-start-or-flows/proposal.md create mode 100644 openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md create mode 100644 openspec/changes/nc-events-start-or-flows/tasks.md create mode 100644 openspec/changes/nextcloud-event-hub-verification/.openspec.yaml create mode 100644 openspec/changes/nextcloud-event-hub-verification/proposal.md create mode 100644 openspec/changes/nextcloud-event-hub-verification/tasks.md delete mode 100644 openspec/changes/nextcloud-event-hub/context-brief.md delete mode 100644 openspec/changes/nextcloud-event-hub/design.md delete mode 100644 openspec/changes/nextcloud-event-hub/discovery.md delete mode 100644 openspec/changes/nextcloud-event-hub/migration.md delete mode 100644 openspec/changes/nextcloud-event-hub/test-plan.md delete mode 100644 openspec/changes/visual-flow-orchestration/design.md delete mode 100644 openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md delete mode 100644 openspec/changes/visual-flow-orchestration/specs/job-management/spec.md delete mode 100644 openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md delete mode 100644 openspec/changes/visual-flow-orchestration/test-plan.md diff --git a/openspec/changes/approvals-verification-pack/.openspec.yaml b/openspec/changes/approvals-verification-pack/.openspec.yaml new file mode 100644 index 000000000..7b2c0fa7b --- /dev/null +++ b/openspec/changes/approvals-verification-pack/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-09-02 diff --git a/openspec/changes/approvals-verification-pack/proposal.md b/openspec/changes/approvals-verification-pack/proposal.md new file mode 100644 index 000000000..29bfd0a29 --- /dev/null +++ b/openspec/changes/approvals-verification-pack/proposal.md @@ -0,0 +1,79 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: approvals-verification-pack + +## Summary + +Close the verification debt the archived `hitl-approval-rule-action` change +left open with honest unticked boxes: the shipped approval surface +(`ApprovalService`, `ApprovalsController`, the Pending Approvals pages, the +rule editor's `approval` form) has unit coverage but no end-to-end proof, no +Newman coverage, no feature documentation and no compiled l10n catalog +entries. This change adds exactly that verification and nothing else: no +approval behavior changes here. + +## Motivation + +The archived twin (`archive/2026-07-15-hitl-approval-rule-action`) checked +31/42 boxes and left the rest open with reasons ("no live instance in this +environment", "not run in this finalization pass"). That debt has sat +invisible inside a superseded 0/42 umbrella ever since. It matters now +because `hitl-on-shared-tasks` is rewiring approval expiry and outcomes onto +OpenRegister's task service: a regression net around the existing +suspend-approve-resume behavior is the difference between that cutover being +verifiable and being hopeful. + +## Affected Projects + +- [x] Project: `integriq` — tests (PHPUnit integration, Newman, Playwright), + docs, l10n. No `lib/` behavior changes. + +## Scope + +### In Scope + +1. PHPUnit integration test: suspend → approve → resume through a real + endpoint rule chain (real `EndpointService` + `ApprovalService` wiring, + faked HTTP/OR edges only), per the archived change's open ADR-009 box. +2. Newman coverage for `/api/approvals*`: list, detail, approve, reject, and + the 403/404/409 error paths, added to the existing Postman collection. +3. Playwright specs for the Pending Approvals list + detail pages (approve + with comment, reject with comment) and the rule editor's `approval` action + form, traced to the scenarios in + `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` + per `hydra-gate-e2e-coverage`. +4. Feature documentation in `docs/`: the `approval` rule action type, the + Synchronization `requiresApproval` gate, the Pending Approvals UI; one + screenshot in `docs/images/`. +5. l10n: `en_US` source strings verified extractable and `nl_NL` catalog + entries for the Approvals UI and the `ApprovalForm.vue` fields. + +### Out of Scope + +- Any change to approval behavior, schemas or routes. +- Expiry/onTimeout/onReject semantics: `hitl-on-shared-tasks` owns their + move onto OR's task service. If that change alters resume mechanics before + this one runs, the tests here assert the behavior at HEAD when written. +- New approval features (delegation, escalation): OR `TaskService` / + `TaskSequenceService` territory, per the superseded umbrella's disposition. + +## Approach + +Test-only PR(s). The integration test lives in `tests/Integration/`, the +Newman additions in `tests/postman/`, the Playwright specs in +`tests/e2e/spec-coverage/`. Docs follow the existing `docs/` layout. + +## Impact + +- `tests/Integration/ApprovalRuleChainTest.php` — new. +- `tests/postman/*.postman_collection.json` — approval scenarios added. +- `tests/e2e/spec-coverage/approval-workflow.spec.ts` — new. +- `docs/` + `docs/images/` — new page + screenshot. +- `l10n/` — catalog entries. + +## Rollback Strategy + +Tests and docs only; revert the PR. No schema, route or service is touched. diff --git a/openspec/changes/approvals-verification-pack/tasks.md b/openspec/changes/approvals-verification-pack/tasks.md new file mode 100644 index 000000000..9f3525825 --- /dev/null +++ b/openspec/changes/approvals-verification-pack/tasks.md @@ -0,0 +1,69 @@ +# Tasks: approvals-verification-pack + +## 1. Integration test + +### Task 1: Suspend → approve → resume through a real rule chain +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `tests/Integration/ApprovalRuleChainTest.php` +- **acceptance_criteria**: + - GIVEN an endpoint with a `before`-phase `approval` rule followed by a later rule WHEN the endpoint is called THEN the response is `202` with a polling URL and an `approval_request` persists the FlowToken snapshot + - GIVEN that request is approved WHEN `ApprovalsController::approve()` runs THEN `processRules()` resumes at the rule after the approval rule and the chain completes + - GIVEN that request is rejected THEN the rule's configured `onReject` outcome is applied +- [ ] Implement +- [ ] Test + +## 2. Newman + +### Task 2: `/api/approvals*` scenarios in the Postman collection +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `tests/postman/` (existing collection) +- **acceptance_criteria**: + - GIVEN the collection runs against a live instance THEN list, detail, approve and reject succeed for an approver-group member + - GIVEN a non-member calls approve THEN `403`; GIVEN a missing id THEN `404`; GIVEN a double approve THEN `409` +- [ ] Implement +- [ ] Test + +## 3. Playwright + +### Task 3: Pending Approvals list + detail +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `tests/e2e/spec-coverage/approval-workflow.spec.ts` +- **acceptance_criteria**: + - GIVEN a pending request WHEN an approver opens the Approvals pages THEN they can approve with a comment and the row leaves the pending list + - GIVEN a pending request THEN reject with a comment records the rejection +- [ ] Implement +- [ ] Test + +### Task 4: Rule editor `approval` action form +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `tests/e2e/spec-coverage/approval-workflow.spec.ts` +- **acceptance_criteria**: + - GIVEN the rule editor WHEN `approval` is chosen as the action type THEN the approver-group, expiry and onReject/onTimeout fields render and persist on save +- [ ] Implement +- [ ] Test + +## 4. Docs & l10n + +### Task 5: Feature documentation + screenshot +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `docs/`, `docs/images/` +- **acceptance_criteria**: + - GIVEN `docs/` THEN a page describes the `approval` rule action, the `requiresApproval` sync gate and the Pending Approvals UI, with one committed screenshot +- [ ] Implement +- [ ] Test + +### Task 6: l10n catalog entries +- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md` +- **files**: `l10n/` +- **acceptance_criteria**: + - GIVEN the Approvals UI strings THEN `nl_NL` catalog entries exist (or the external localization pipeline demonstrably carries them; record which) +- [ ] Implement +- [ ] Test + +## Verification +- [ ] All tasks checked off +- [ ] Manual testing against acceptance criteria +- [ ] Code review against spec requirements + +## Tests (company-wide ADR-009) +- [ ] All tests pass (`composer test`, `newman run`, Playwright suite) diff --git a/openspec/changes/hitl-approval-rule-action/context-brief.md b/openspec/changes/hitl-approval-rule-action/context-brief.md deleted file mode 100644 index d61c137ac..000000000 --- a/openspec/changes/hitl-approval-rule-action/context-brief.md +++ /dev/null @@ -1,25 +0,0 @@ -# Context Brief: hitl-approval-rule-action -Source: Specter deep-research 2026-07-14 (insight #1264). VERIFY every code claim against HEAD before writing artifacts. - -## Problem / Opportunity -No human-in-the-loop step exists in Integriq flows. n8n ships native approval steps + fallback logic; Windmill has approval/suspend steps (integrated with NC Talk in integration_windmill); Workato has HITL. Municipal integration flows need "a human approves before data leaves/enters" (e.g. publish to WOO portal, outbound message to Berichtenbox). - -## Current state (verify at HEAD) -- Rules: ordered pre/post endpoint rules with JsonLogic conditions; 16+ action types incl. synchronization-trigger, webhook_signature (see EndpointService + src/views/Rule/actionForms/). -- Synchronizations trigger via cron/webhook/rule/manual. -- openconnector-notifications change (ADR-031 dialect) exists — check its state; NC notifications available via OCP\Notification. -- flow-token-helper spec: 8-slot request/response/sync snapshots through rule pipeline (the suspension/resume substrate candidate). - -## In scope -1. New rule action type `approval`: when hit, the pipeline run SUSPENDS: persist an ApprovalRequest entity (context snapshot, requester, approver group, expiry) as an OR object; notify approver group via NC notifications (approve/reject actions deep-linking into Integriq UI). -2. Approve → pipeline resumes from suspension point with original context; Reject or timeout → configurable outcome (error response, skip action, route to dead-letter). -3. Synchronization gate: optional "requires approval" on a synchronization — run pauses before write phase until approved (batch-level, not per-object). -4. Approvals UI: pending approvals list page + approve/reject with comment; full audit (who/when/comment) on the ApprovalRequest object (OR audit trail). -5. Authorization via ADR-023 action matrix (approval.approve, approval.reject grants). -6. Tests: unit for state machine (pending/approved/rejected/expired); integration for suspend→approve→resume through an endpoint rule. -## Out of scope -- Talk-message approvals (notifications only, Talk later), multi-step quorum approvals, decidesk integration. - -## Constraints -- Suspension must survive PHP process boundaries (persisted state + resume via background job or next request — design decision for design.md; NO long-running processes). -- Specs: new capability spec + deltas to rule-pipeline, synchronization-engine; notifications must follow ADR-031 x-openregister-notifications dialect (check hydra gate notification-dialect). diff --git a/openspec/changes/hitl-approval-rule-action/contract.md b/openspec/changes/hitl-approval-rule-action/contract.md deleted file mode 100644 index a593e5370..000000000 --- a/openspec/changes/hitl-approval-rule-action/contract.md +++ /dev/null @@ -1,45 +0,0 @@ -# Contract: hitl-approval-rule-action - -## Consumers - -None. Per proposal.md's "Cross-Project Dependencies" section, this change -is self-contained within Integriq. No other `apps-extra` project calls -`/api/approvals*`, reads `approval_request` objects directly, or depends on -the `approval` rule action type. The only cross-app dependency is -OpenRegister object persistence/audit, which is already a required runtime -dependency of every Integriq schema (not a new contract introduced by -this change). - -This document is therefore not applicable in its full form — see -design.md's API Design section for the concrete endpoint definitions -(`GET /api/approvals`, `GET /api/approvals/{id}`, -`POST /api/approvals/{id}/approve`, `POST /api/approvals/{id}/reject}`), -which remain Integriq-internal (consumed only by its own SPA). - -## Endpoints - -See `design.md` → API Design for the full request/response/error shapes. -Not duplicated here since there are no external consumers to align with. - -## Error Codes - -See `design.md` → API Design's per-endpoint error tables (403, 404, 409, -500) and `openspec/specs/approval-workflow/spec.md` REQ-003/REQ-004/REQ-006 -for the scenarios each code corresponds to. - -## Versioning - -Internal-only REST surface, versioned implicitly with the Integriq app -release (no separate API version). No external consumer commitment is made. - -## Breaking Change Policy - -N/A — no external consumers. Changes to `/api/approvals*` only need to stay -in sync with the Integriq frontend SPA, tracked in the same change/PR. - -## SLA - -N/A — internal admin/approver-facing UI, not a service-level surface. The -only latency consideration is design.md Risk 2 (resumed rule chain runs -inside the approver's own request), which is a UX trade-off documented -there, not an SLA commitment. diff --git a/openspec/changes/hitl-approval-rule-action/discovery.md b/openspec/changes/hitl-approval-rule-action/discovery.md deleted file mode 100644 index b553f0133..000000000 --- a/openspec/changes/hitl-approval-rule-action/discovery.md +++ /dev/null @@ -1,124 +0,0 @@ -# Discovery: hitl-approval-rule-action - -## Question - -Can a HITL approval action be suspended and resumed entirely within -Integriq's existing mechanisms — the rule pipeline's short-circuit -contract, the FlowToken snapshot, the ADR-031 declarative notification -dialect, and the ADR-023 action-authorization matrix — or does it require -new infrastructure (long-running processes, a new response type, imperative -notification dispatch, a second authorization system)? - -## Approach Taken - -- Read `lib/Service/EndpointService.php` in full around - `doHandleRequest()` (lines 257-430) and `processRules()` (lines 1538-1661) - to trace the exact before/dispatch/after orchestration and the existing - `JSONResponse`/`DataDownloadResponse` short-circuit contract. -- Confirmed the live rule-type dispatch table (19 types) in `processRules()`'s - `match` expression — the brief's "synchronization-trigger" name does not - exist in code; the real type is `synchronization`. -- Read `openspec/specs/flow-token-helper/spec.md` (done, retrofit) to confirm - `FlowToken::__serialize()` produces a fixed 8-key array (request/response/ - syncInput/syncOutput × original/amended) suitable as a suspension payload. -- Read `openspec/specs/synchronization-engine/spec.md` (done, retrofit) to - find the read/map/write phase boundary (`updateTarget()` / - `updateTargetOpenRegister()` in REQ-004) where a batch-level approval gate - would sit. -- Read `openspec/specs/openconnector-notifications/spec.md` and grepped - every `x-openregister-notifications` occurrence in - `lib/Settings/integriq_register.json` (9 occurrences) to enumerate - every `trigger`/`recipients`/`channels` shape actually in use. -- Grepped the whole `lib/` tree for `OCP\Notification` / `INotificationManager` - usage — zero hits. Every existing notification in this app goes through - the declarative engine; there is no precedent for imperative dispatch. -- Read `lib/Service/ActionAuthService.php` (full) and - `lib/Repair/InitializeActions.php` to confirm the ADR-023 matrix shape: - one global action-name → allowed-groups mapping in `IAppConfig`, `["admin"]` - default, admin always passes. -- Grepped `lib/Settings/register.d/*.json` (eudi-wallet-credential-issuance, - source fragments) to confirm the ADR-037 register-fragment pattern for - adding new schemas without editing the 2000+ line base register file. -- Grepped `lib/BackgroundJob/*.php` — all five existing cron jobs `extends TimedJob` - (NC's `OCP\BackgroundJob\TimedJob`), confirming the periodic-sweep idiom - already used for `EventRetryJob`. - -## Findings - -1. **Suspension needs no new Response type.** `doHandleRequest()` already - short-circuits on `$ruleResult instanceof JSONResponse` right after the - `before`-phase `processRules()` call (line 354). An `approval` rule can - return a `JSONResponse(202, ...)` from inside `processRules()`'s - `match` dispatch and the existing contract carries it out unchanged. -2. **FlowToken is a ready-made suspension payload.** `__serialize()` - (REQ-005 of flow-token-helper) already produces exactly what's needed to - resume: the amended request the pipeline had built up to the approval - rule. No new snapshot format needed. Caveat: there is no matching - `__unserialize()` (documented gap in the flow-token-helper spec) — resume - must reconstruct a fresh `FlowToken` via the public setters from the - deserialized 8-key array, not rely on native unserialize. -3. **The declarative notification dialect cannot express this feature's - primary notification.** Every one of the 9 existing - `x-openregister-notifications` blocks uses only two recipient kinds: - `field` (a single userId property on the created object) and `groups` - (a **static**, schema-level list of NC group names). Neither can express - "notify the group chosen when this specific rule was configured." None - of the 9 blocks contain any action/button configuration — the dialect - only carries `trigger`/`enabled`/`channels`/`recipients`/`subject`. - Approve/reject deep-link actions are not expressible declaratively. -4. **No imperative-notification precedent exists in this app.** This is a - green field for `OCP\Notification\IManager` in Integriq. That's a - real behavioral departure worth calling out explicitly rather than - quietly introducing. -5. **ADR-023's matrix is one flat mapping, not per-instance.** `requireAction()` - checks group membership against a single app-wide entry for the action - name. It has no concept of "the approver group for *this* - `approval_request`." A second, object-level check is required for the - per-rule-configured approver group; ADR-023's own docblock already - draws this line ("Data RBAC ... is OpenRegister's job" vs. "Action RBAC - ... is this service"), so layering a group-membership check on the - object's own `approverGroup` field alongside `requireAction()` is - consistent with the existing division of responsibility, not a new - pattern. -6. **The register-fragment pattern fits cleanly.** `approval_request` is a - brand-new schema; `lib/Settings/register.d/*.json` is the established, - already-used mechanism (ADR-037) for adding schemas without touching the - base register file. -7. **Cron sweep fits the existing `TimedJob` idiom.** Timeout sweeping - (`pending` + `expiresAt < now`) is a straight fit for a new - `ApprovalTimeoutSweepJob extends TimedJob`, mirroring `EventRetryJob`. - -## Recommendation - -Go with the all-existing-mechanisms approach: reuse the `JSONResponse` -short-circuit, `FlowToken::__serialize()`, the register-fragment pattern, -and `TimedJob` cron — **except** for the primary approver notification, -which must be dispatched imperatively via `OCP\Notification\IManager` -because the declarative dialect structurally cannot express a per-rule -dynamic approver group or interactive actions. Keep the imperative path -narrowly scoped to one `ApprovalService` method, and keep a declarative -`x-openregister-notifications` rule on `approval_request` for ops-visibility -(static `openconnector-ops` group) so the app's dominant notification -pattern still applies where it can. Resume approve/reject synchronously in -the approver's own request (a distinct PHP process from the suspended -original request, satisfying "no long-running process") rather than -queuing a background job for the common case — reserve background/cron -execution for timeout sweeping only, where no human is waiting on the -response. - -## Risks Uncovered - -- Reconstructing `FlowToken` from a serialized snapshot must go through the - public setters (no `__unserialize()`), so the resume path in - `ApprovalService` needs its own explicit "rehydrate" helper rather than - `unserialize($snapshot)`. -- The imperative notification dispatch is genuinely new territory for this - app; it should be reviewed as such (see proposal.md Risk 1), not treated - as routine. - -## Next Steps - -Proceed to design.md to fix the exact suspend/resume state machine, the -`approval_request` schema shape, and the two-layer authorization model; then -to specs (new `approval-workflow` capability + deltas to `rule-pipeline` and -`synchronization-engine`). diff --git a/openspec/changes/hitl-approval-rule-action/migration.md b/openspec/changes/hitl-approval-rule-action/migration.md deleted file mode 100644 index f237c0316..000000000 --- a/openspec/changes/hitl-approval-rule-action/migration.md +++ /dev/null @@ -1,85 +0,0 @@ -# Migration: hitl-approval-rule-action - -## Current State - -No `approval_request` schema, `approval` rule action type, or -`requiresApproval` Synchronization flag exists. `lib/actions.seed.json` has -no `approval.approve`/`approval.reject` entries. - -## Target State - -An `approval_request` schema exists on the `openconnector` OpenRegister -register (added via a `register.d` fragment, not a raw SQL table — see -below), plus the new `approval.approve`/`approval.reject` ADR-023 -action-matrix entries and the new cron job registration. - -## Migration Class - -**None.** This change does not require an `lib/Migration/VersionXXXXXXXXXX.php` -class. Integriq schemas (like every other schema in this app, -including the most recently added `eudi_credential_offer`/ -`eudi_issuance_session`/`eudi_status_list` trio from the -`eudi-wallet-credential-issuance` change, which also shipped without a -migration.md/Migration class) are OpenRegister objects, not custom SQL -tables — they are declared via a `register.d/*.json` fragment -(`lib/Settings/register.d/hitl-approval-rule-action.json`) and picked up by -the existing `lib/Repair/InitializeRegister.php` repair step's -`deepMergeConfig()`, which merges the fragment into the live register -descriptor on app boot/upgrade. No `changeSchema()` / NC `IOutput` DB -migration is involved. - -The ADR-023 action-matrix entries are seeded the same way as every other -action in this app: via `lib/actions.seed.json` + the existing -`InitializeActions` repair step (also not an NC Migration class). - -## Migration Steps - -1. Ship `lib/Settings/register.d/hitl-approval-rule-action.json` declaring - the `approval_request` schema (Task 1). -2. Ship the two new entries in `lib/actions.seed.json` (Task 2). -3. On app upgrade, `InitializeRegister`'s repair step merges the new schema - into the `openconnector` register; `InitializeActions`'s repair step adds - the two new action-matrix entries (both repair steps already run - idempotently on every upgrade — no new repair-step registration needed). -4. Register `ApprovalTimeoutSweepJob` in `lib/AppInfo/Application.php` - alongside the existing five cron jobs (Task 10) — NC's `IJobList` - auto-registers `TimedJob` subclasses declared in `Application::register()` - on next app load; no separate activation step. -5. Ship seed data (Task 15) so the feature is demoable immediately after - upgrade (company-wide ADR-016). - -Each step is independently deployable and additive — none of them modify or -require reprocessing of existing data. - -## Data Impact - -Zero existing records are affected — no existing schema, table, or object -is modified, renamed, or reshaped. Zero data loss. Fully safe to run on live -data; the new schema and action-matrix entries are additive-only and the app -functions identically for every existing endpoint/synchronization until an -admin explicitly configures an `approval` rule or `requiresApproval` flag. - -## Rollback Procedure - -Since there is no NC Migration class, there is no `changeSchema()` to -reverse. Rollback is: revert the app to a prior release (or the specific -commit range). Any `approval_request` objects already created during the -rolled-back window become orphaned-but-harmless OpenRegister objects (no -foreign keys from other schemas point at them); they can be left in place -or bulk-deleted via the standard OpenRegister object-management UI/CLI if a -clean rollback is desired. The two `actions.seed.json` entries and the -`ApprovalTimeoutSweepJob` registration disappear on rollback along with the -reverted code; no separate "unseed" step is needed since seeding is -idempotent-on-presence, not destructive. - -## Validation - -- `python3 -c "import json;json.load(open('lib/Settings/register.d/hitl-approval-rule-action.json'))"` - parses without error (mirrors the existing validation step used by - `openconnector-notifications`'s tasks.md). -- After upgrade, confirm the `approval_request` schema is queryable via the - OpenRegister admin UI (Settings > OpenRegister > openconnector register). -- Confirm `approval.approve`/`approval.reject` appear in Admin Settings > - Integriq > Action Authorization, both defaulting to `admin`. -- Confirm `ApprovalTimeoutSweepJob` appears in `occ background-job:list` - after the next app load. diff --git a/openspec/changes/hitl-approval-rule-action/proposal.md b/openspec/changes/hitl-approval-rule-action/proposal.md index a17569886..6ecb1255f 100644 --- a/openspec/changes/hitl-approval-rule-action/proposal.md +++ b/openspec/changes/hitl-approval-rule-action/proposal.md @@ -1,206 +1,52 @@ -# Proposal: hitl-approval-rule-action - -## Summary - -Add a human-in-the-loop (HITL) `approval` rule action type to Integriq's -endpoint rule pipeline, plus an optional batch-level approval gate on -Synchronizations. When a pipeline run hits an `approval` rule (timing -`before` only), the run suspends: an `approval_request` OpenRegister object -persists a snapshot of the in-flight FlowToken, the target endpoint/rule -context, the configured approver group, and an expiry. The original HTTP -caller receives `202 Accepted` with a status-polling URL instead of a final -result. An approver group member approves or rejects (with comment) through -a new Pending Approvals UI; approval resumes the suspended rule chain -synchronously inside the approver's own request; rejection or timeout apply -a per-rule-configured fallback (`error`, `skip`, or `dead_letter`). This -closes a gap competitors (n8n, Windmill, Workato) already cover and is -required for flows where a human must gate data leaving/entering municipal -systems (e.g. publish to a WOO portal, outbound Berichtenbox message). - -## Motivation - -Integriq's rule pipeline (retrofit spec `rule-pipeline`) and -Synchronization engine (`synchronization-engine`) currently have no way to -pause a run for human sign-off — every rule type (`save_object`, -`synchronization`, `webhook_signature`, `composite_fanout`, etc., 19 types -verified at HEAD in `EndpointService::processRules()`) runs to completion or -fails within the same request. Municipal integration flows increasingly need -"a human approves before data leaves/enters" as a compliance control, not -just a business nicety. Without this, admins either skip the control -entirely or bolt it onto the target system (out of Integriq's audit -trail). Building it as a first-class rule action keeps the approval decision, -its context snapshot, and its audit trail inside Integriq, consistent -with how `audit_trail`, `locking`, and the ADR-023 action matrix already -work. - -## Affected Projects - -- [ ] Project: `integriq` — new `approval` rule action type, suspend/resume - mechanics in `EndpointService`, new `approval_request` register schema - (register.d fragment), `ApprovalService`, `ApprovalsController` + - frontend Pending Approvals page, `ApprovalTimeoutSweepJob` cron, ADR-023 - `approval.approve`/`approval.reject` action-matrix entries, optional - `requiresApproval` gate on Synchronization batch writes. - -## Scope - -### In Scope - -1. New rule action type `approval` (timing `before` only): on hit, persists - an `approval_request` OR object (context snapshot via - `FlowToken::__serialize()`, requester, approver group, expiry, - suspension cursor), short-circuits the pipeline with `202 Accepted` + - a status-polling URL (reusing the existing `JSONResponse` short-circuit - contract in `doHandleRequest()`), and notifies the approver group. -2. Approve → the approving user's own HTTP request synchronously resumes - `processRules()` from the rule immediately after the approval rule (same - phase, filtered by `order`), using the persisted FlowToken snapshot as - the resumed data envelope, then continues normal dispatch (schema - write / `after`-phase rules). Reject or timeout → configurable outcome - per the approval rule's `onReject`/`onTimeout` config: `error` (return - a configured error to a status poller), `skip` (mark suspended and move - on with the pre-approval envelope), or `dead_letter` (mark the - `approval_request` itself `status: dead_letter`, discoverable via a - dead-lettered-approvals filter — no separate event_message row). -3. Synchronization gate: optional `requiresApproval` on a Synchronization — - the run pauses once source fetch + mapping complete and before - `updateTarget()` writes begin, gating the whole batch (not per-object). - Resume re-invokes `synchronize()` with a bypass token tied to the - approved `approval_request` id rather than re-serializing the full - batch payload. -4. Pending Approvals UI: list page (pending / approved / rejected / expired - / dead-lettered filters) + approve/reject action with a required comment - on reject; full audit (who/when/comment/outcome) lives on the - `approval_request` object via OpenRegister's existing audit trail. -5. Two-layer authorization: ADR-023 `approval.approve` / `approval.reject` - action-matrix entries (coarse "can use approval features" gate, seeded - `["admin"]`) plus a per-request check that the caller is a member of - *that* `approval_request`'s configured `approverGroup` (fine-grained, - since the approver group is chosen per-rule, not app-wide). -6. Notifications: an `x-openregister-notifications` `created` rule on - `approval_request` for ops-visibility (declarative ADR-031 dialect, - static `openconnector-ops` group, no interactive actions — matches the - existing dialect's proven shape). The primary actionable approve/reject - notification to the *rule-configured* approver group is dispatched - imperatively by `ApprovalService` via `OCP\Notification\IManager` — see - Risk 1 and design.md for why this can't be expressed in the declarative - dialect alone. -7. Tests: unit tests for the `approval_request` state machine - (pending/approved/rejected/expired/dead_letter transitions); integration - test for suspend → approve → resume through a real endpoint rule chain. - -### Out of Scope - -- Nextcloud Talk approval messages (notification-only for v1; Talk - integration is a documented follow-up). -- Multi-step / quorum approvals (single approver from the configured group - resolves the request). -- decidesk integration. -- Replaying a dead-lettered approval against fresh source data (the - persisted snapshot can go stale; replay needs its own staleness story — - deferred, see Open Questions). -- `approval` on `after`-timing rules or mid-Synchronization per-object - gating (only batch-level, pre-write gating is in scope). - -## Approach - -Reuse the FlowToken 8-slot snapshot (`flow-token-helper`, already the -substrate carried through the rule pipeline) as the suspension payload — -no new serialization format. Reuse the existing `JSONResponse` short-circuit -contract in `EndpointService::doHandleRequest()`/`processRules()` so -suspension doesn't require a new Response subclass. Persist suspension state -as a normal OpenRegister object (`approval_request`) via a `register.d` -fragment (ADR-037 pattern, avoids touching the 2000+-line -`integriq_register.json` directly). Resume executes synchronously -inside the approving user's own request (a separate PHP process boundary -from the original suspended request, satisfying "no long-running process" -without needing NC background-job/cron latency); only timeout sweeping runs -on a cron `TimedJob` (matching the existing `lib/BackgroundJob/EventRetryJob.php` -pattern). See design.md for the full suspend/resume state machine and the -notification-dialect decision. - -## New Dependencies - -None — reuses existing OpenRegister object persistence, FlowToken -serialization, `ActionAuthService`, NC's `IJobList`/`TimedJob`, and NC's -`OCP\Notification\IManager` (already a platform dependency, just not -previously called from this app). - -## Impact - -- `lib/Service/EndpointService.php` — new `processApprovalRule()` dispatch - case in `processRules()`'s match expression; `doHandleRequest()` gains no - new branches (the 202 short-circuit reuses the existing `JSONResponse` - path). -- `lib/Service/SynchronizationService.php` — new pre-write approval gate - check in the orchestration path (REQ-001/REQ-004 boundary). -- New: `lib/Service/ApprovalService.php`, `lib/Controller/ApprovalsController.php`, - `lib/BackgroundJob/ApprovalTimeoutSweepJob.php`, `lib/Settings/register.d/hitl-approval-rule-action.json`. -- `lib/actions.seed.json` — add `approval.approve`, `approval.reject`. -- `src/views/Rule/RuleActionConfig.vue` — new `approval` entry in - `ACTION_TYPES`/`ACTION_FORM_MAP`; new `src/views/Rule/actionForms/ApprovalForm.vue`. -- New: `src/views/Approvals/` (Pending Approvals list + detail). -- `appinfo/routes.php` — new approvals REST routes. - -## Cross-Project Dependencies - -None. Self-contained within Integriq; consumes only OpenRegister object -persistence/audit (already a dependency) and NC's notification API (already -a platform dependency). - -## Risks - -### Risk 1: Actionable approver notification needs imperative dispatch, breaking a clean codebase precedent -**Severity:** Medium — **Mitigation:** every existing Integriq -notification is declarative (`x-openregister-notifications` in -`lib/Settings/integriq_register.json`, verified across all 9 current -occurrences: only `channels`/`recipients` of kind `field` or `groups`/ -`subject` — no interactive-action support, and `groups` recipients are -static per schema, not resolvable from a per-rule-configured field). Since -the approver group is chosen per-rule and the notification needs -approve/reject deep-link actions, `ApprovalService` dispatches imperatively -via `OCP\Notification\IManager`, scoped to one method. `hydra-gate-notification-dialect` -WARNS (non-blocking) on imperative dispatch in a leaf app — this is that -documented exception, not a violation. The declarative dialect still covers -ops-visibility notifications. - -### Risk 2: Resumed rule chain re-runs inside the approver's HTTP request -**Severity:** Medium — **Mitigation:** if the resumed rule chain is slow -(e.g. it triggers a synchronization rule), the approving admin's browser -waits on that latency. Bounded by the same execution-time profile any -endpoint request already has; `approve()` returns the resume result -(success/error) directly rather than polling. A future iteration could move -resume onto a background job if this proves too slow in practice — noted as -a follow-up, not blocking v1. - -### Risk 3: Stale snapshot on approval -**Severity:** Low — **Mitigation:** the resumed FlowToken snapshot reflects -the state at suspension time; source data or target schema may have -changed by the time approval happens. The `approval_request` records -`expiresAt`; an expired request cannot be approved (falls to the -timeout fallback instead), bounding the staleness window. - -## Rollback Strategy - -The `approval` rule action type and `requiresApproval` Synchronization flag -are both additive and opt-in — no existing rule or synchronization behavior -changes unless an admin explicitly configures one. Rollback is: stop -configuring new `approval` rules / `requiresApproval` synchronizations; -existing `approval_request` objects and the `ApprovalTimeoutSweepJob` cron -entry can be left in place (inert) or the app can be reverted to a prior -release, since the new schema and routes are additive-only (no migration of -existing data). - -## Open Questions - -- Should a dead-lettered `approval_request` be replayable (re-running the - suspended chain against a fresh fetch rather than the stale snapshot)? Out - of scope for v1 pending a staleness-handling design; tracked as a - follow-up alongside `dead-letter-replay`'s established list/detail/replay - UX, which this deliberately does not reuse directly (different domain: - CloudEvent delivery vs. rule-pipeline suspension). -- Should `requireAction` gate `approval.approve`/`approval.reject` ever - diverge from `["admin"]` default in a way that conflicts with the - per-`approval_request` `approverGroup` check? Deferred to design.md's - two-layer authorization model; flag if real deployments need a single - unified matrix instead. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: hitl-approval-rule-action (superseded — re-scoped 2026-09-02) + +This directory double-counted a change that had already shipped. The +`approval` rule action was implemented and archived on 2026-07-15 +(`archive/2026-07-15-hitl-approval-rule-action`, PR #181, 31/42 tasks checked +with per-task evidence), yet this live copy was left standing at 0/42. The +machinery exists at HEAD: `ApprovalService`, `ApprovalsController`, +`ApprovalTimeoutSweepJob`, the `approval_request` schema in +`lib/Settings/register.d/hitl-approval-rule-action.json`, the suspend/resume +path in `EndpointService`, the Pending Approvals UI, and the ADR-023 +`approval.approve`/`approval.reject` matrix entries. + +The One-engine direction also moves this change's ownership boundary. Expiry +and outcome semantics (`expiresAt`, `onTimeout`, `onReject`) now belong to +OpenRegister's shared task service (openregister change +`task-expiry-and-outcomes`), and Integriq's adoption of it is a separate, +in-flight change: `hitl-on-shared-tasks` (its first PR, #1812, is merged; +its remaining tasks continue there — do not duplicate them here). + +13 `@spec` tags in `lib/` and `tests/` point at this directory's `design.md` +and `specs/approval-workflow/spec.md`, so those two files stay exactly where +they are as anchor targets. The other artifacts (context brief, contract, +discovery, migration, test plan) are removed; they survive verbatim in the +archived twin and in git history. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `approval` rule action, `approval_request` schema, `ApprovalService`, suspend/resume in `EndpointService`, `ApprovalTimeoutSweepJob`, Pending Approvals UI, ADR-023 matrix entries, Synchronization `requiresApproval` gate (tasks 1-12, 15-16) | **Already shipped and archived**: `archive/2026-07-15-hitl-approval-rule-action` (PR #181), code at HEAD | +| Expiry, `onTimeout`, `onReject` enforcement | **Moving onto OR's task service**: openregister `task-expiry-and-outcomes` owns the vocabulary and the shared sweep; Integriq adopts it through `openspec/changes/hitl-on-shared-tasks` (in flight, first PR #1812 merged) | +| Residual verification: suspend-approve-resume integration test, Newman for `/api/approvals*`, Playwright for the Approvals pages and the rule editor's approval form, feature docs, screenshot, l10n catalog entries | `openspec/changes/approvals-verification-pack` — **first shippable slice**, fully authored | +| Future approval-shaped features (delegation, escalation, multi-step sign-off chains) | Target OR primitives (`TaskService`, `TaskSequenceService`); no new app-local approval machinery. A disposition, not a change | + +## Sequencing + +`approvals-verification-pack` is independent and ready to hand to an agent +today. `hitl-on-shared-tasks` continues on its own track. Nothing remains to +implement from this change directly. + +## Archival + +This directory is retired in place (not moved or renamed): its `design.md` +and `specs/approval-workflow/spec.md` are live `@spec` anchor targets, and a +rename would both break those tags and detonate every diff-scoped gate. +Archive it via the normal flow only after those tags are repointed at the +main specs. diff --git a/openspec/changes/hitl-approval-rule-action/tasks.md b/openspec/changes/hitl-approval-rule-action/tasks.md index 7aa50e70c..5cfcb0aab 100644 --- a/openspec/changes/hitl-approval-rule-action/tasks.md +++ b/openspec/changes/hitl-approval-rule-action/tasks.md @@ -1,157 +1,14 @@ -# Tasks: hitl-approval-rule-action +# Tasks: hitl-approval-rule-action (superseded) -## Implementation Tasks +The original 16-task / 42-checkbox list is removed with the 2026-09-02 +re-scope (see proposal.md for the full disposition; the list survives in +`archive/2026-07-15-hitl-approval-rule-action/tasks.md`, where 31/42 boxes are +checked with per-task evidence, and in git history). Work items live in the +successor changes: -### Task 1: Declare the `approval_request` register schema -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/design.md#database-changes` -- **files**: `lib/Settings/register.d/hitl-approval-rule-action.json` -- **acceptance_criteria**: - - GIVEN a fresh install WHEN the app boots THEN `lib/Repair/InitializeRegister.php` merges the fragment and the `approval_request` schema exists on the `openconnector` register with the fields listed in design.md's Database Changes table - - GIVEN the fragment JSON WHEN parsed THEN it is valid JSON and does not redeclare any existing schema slug -- [ ] Implement -- [ ] Test +- `approvals-verification-pack` (ready to start) +- `hitl-on-shared-tasks` (in flight, first PR #1812 merged) -### Task 2: Seed ADR-023 action-matrix entries -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-006-two-layer-authorization-for-approvereject` -- **files**: `lib/actions.seed.json` -- **acceptance_criteria**: - - GIVEN a fresh install WHEN `InitializeActions` runs THEN `approval.approve` and `approval.reject` exist in the matrix, both defaulting to `["admin"]` - - GIVEN an existing non-empty matrix (upgrade case) WHEN the repair step runs THEN existing customized entries are left untouched (matches `InitializeActions`'s documented preserve-on-upgrade behavior) -- [ ] Implement -- [ ] Test - -### Task 3: `ApprovalService::suspend()` — persist ApprovalRequest and short-circuit -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-001-endpoint-rule-pipeline-suspension-on-approval-action` -- **files**: `lib/Service/ApprovalService.php` -- **acceptance_criteria**: - - GIVEN a `before`-timing `approval` rule whose conditions pass WHEN `suspend()` runs THEN an `approval_request` object is created with `status: pending`, `resumeOrder` set to the rule's `order`, a stripped `FlowToken` snapshot (no `Authorization` header), `approverGroup`, `onReject`/`onTimeout`, and `expiresAt` - - GIVEN an `approval` rule configured with `timing: after` WHEN it is evaluated THEN the configuration is rejected (no suspension attempted) -- [ ] Implement -- [ ] Test - -### Task 4: Wire `approval` into `EndpointService::processRules()` -- **spec_ref**: `openspec/specs/rule-pipeline/spec.md#req-rule-008-approval-rule-action-type-suspends-the-pipeline` -- **files**: `lib/Service/EndpointService.php` -- **acceptance_criteria**: - - GIVEN an endpoint with rules at order 10/20(`approval`)/30 WHEN the before-phase pipeline runs and order 20's conditions pass THEN order 10 runs, order 20 suspends via `ApprovalService::suspend()` returning `JSONResponse(202, ...)` through the existing short-circuit check in `doHandleRequest()`, and order 30 does not run in this request -- [ ] Implement -- [ ] Test - -### Task 5: `ApprovalService::notifyApprovers()` — imperative actionable notification -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-002-approver-notification-on-suspension` -- **files**: `lib/Service/ApprovalService.php` -- **acceptance_criteria**: - - GIVEN an `approval_request` created with `approverGroup: "woo-approvers"` WHEN `notifyApprovers()` runs THEN every member of that NC group receives a notification with approve/reject actions deep-linking to `/apps/integriq/approvals/{id}` -- [ ] Implement -- [ ] Test - -### Task 6: Declarative ops-visibility notification on `approval_request` -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-002-approver-notification-on-suspension` -- **files**: `lib/Settings/register.d/hitl-approval-rule-action.json` -- **acceptance_criteria**: - - GIVEN any `approval_request` is created WHEN the declarative `x-openregister-notifications` `created` rule evaluates THEN the `openconnector-ops` group receives a notification via the OpenRegister notification engine, matching the shape already used by `openconnector-notifications`'s existing rules -- [ ] Implement -- [ ] Test - -### Task 7: `ApprovalService::rehydrateFlowToken()` and `resume()` — approval path -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-003-resume-on-approval` -- **files**: `lib/Service/ApprovalService.php` -- **acceptance_criteria**: - - GIVEN a `pending`, non-expired `approval_request` at `resumeOrder: 20` for an endpoint whose chain has a `save_object` rule at order 30 WHEN an authorized approver approves it THEN the FlowToken is rehydrated via public setters (no native `unserialize`), rules with `order > 20` in the same phase run, the schema/target dispatch and `after`-phase rules complete normally, `status` becomes `approved`, and `resumeResult` is set to `success`/`error` accordingly - - GIVEN an `approval_request` with `status: approved` WHEN a second approve call targets the same id THEN the system returns HTTP 409 and does not re-run the chain -- [ ] Implement -- [ ] Test - -### Task 8: `ApprovalsController::approve()` / `reject()` — two-layer authorization -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-006-two-layer-authorization-for-approvereject` -- **files**: `lib/Controller/ApprovalsController.php` -- **acceptance_criteria**: - - GIVEN a user in the app-wide `approval.approve` action-matrix group but NOT a member of the request's `approverGroup` WHEN they call approve THEN HTTP 403 and `status` remains `pending` - - GIVEN a non-admin user in `approverGroup` but not in the `approval.approve` action-matrix group WHEN they call approve THEN HTTP 403 (matrix check fails first) - - GIVEN an NC admin not in `approverGroup` WHEN they call approve THEN the approval succeeds - - GIVEN a reject call with an empty comment WHEN submitted THEN HTTP 400 and the request remains `pending` -- [ ] Implement -- [ ] Test - -### Task 9: Reject flow — mandatory comment, `onReject` outcomes -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-004-rejection-with-mandatory-audit-comment` -- **files**: `lib/Service/ApprovalService.php`, `lib/Controller/ApprovalsController.php` -- **acceptance_criteria**: - - GIVEN a `pending` request configured `onReject: error` WHEN an authorized approver rejects with a comment THEN `status` becomes `rejected`, `approverUserId`/`rejectedAt`/`comment` are recorded, and the configured error outcome resolves without further rule execution or target write -- [ ] Implement -- [ ] Test - -### Task 10: `ApprovalTimeoutSweepJob` cron + synchronous expiry re-check -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-005-timeout-sweeping-and-fallback-outcomes` -- **files**: `lib/BackgroundJob/ApprovalTimeoutSweepJob.php`, `lib/AppInfo/Application.php`, `lib/Controller/ApprovalsController.php` -- **acceptance_criteria**: - - GIVEN a `pending` request with `expiresAt` in the past and `onTimeout: dead_letter` WHEN `ApprovalTimeoutSweepJob` runs THEN `status` becomes `dead_letter` - - GIVEN a `pending` request with `expiresAt` in the past that the sweep job has not yet processed WHEN an approver attempts to approve it THEN HTTP 409 and the pipeline is not resumed -- [ ] Implement -- [ ] Test - -### Task 11: Synchronization batch approval gate -- **spec_ref**: `openspec/specs/synchronization-engine/spec.md#req-006-batch-level-approval-gate-before-target-writes` -- **files**: `lib/Service/SynchronizationService.php`, `lib/Service/ApprovalService.php` -- **acceptance_criteria**: - - GIVEN a Synchronization with `sourceConfig.requiresApproval: true` and no existing `approval_request` WHEN `synchronize()` runs and fetch+mapping complete THEN an `approval_request` (with `synchronizationId` set) is created, the approver group is notified, the `synchronization_log` records a `pending_approval` outcome, and no target writes/garbage-collection occur - - GIVEN that request is approved WHEN `ApprovalService::resume()` re-invokes `synchronize(force: true)` with the approved request's id THEN the gate passes, all writes proceed, and the request is marked consumed - - GIVEN `sourceConfig.requiresApproval` is absent/false WHEN `synchronize()` runs THEN behavior is unchanged from before this change -- [ ] Implement -- [ ] Test - -### Task 12: `ApprovalsController` REST routes -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/design.md#api-design` -- **files**: `appinfo/routes.php`, `lib/Controller/ApprovalsController.php` -- **acceptance_criteria**: - - GIVEN the routes are registered WHEN `GET /api/approvals`, `GET /api/approvals/{id}`, `POST /api/approvals/{id}/approve`, `POST /api/approvals/{id}/reject` are called by an authenticated user THEN each resolves to the corresponding controller method with standard NC CSRF protection applied (no `#[NoCSRFRequired]`) -- [ ] Implement -- [ ] Test - -### Task 13: `approval` rule action type in the Rule editor UI -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-007-pending-approvals-ui` -- **files**: `src/views/Rule/RuleActionConfig.vue`, `src/views/Rule/actionForms/ApprovalForm.vue` -- **acceptance_criteria**: - - GIVEN the Rule detail page WHEN an admin picks action type "Approval" THEN `ApprovalForm.vue` renders fields for approver group (NC group picker), TTL, `onReject`, and `onTimeout`, matching the existing per-type form convention (`ACTION_TYPES`/`ACTION_FORM_MAP`) -- [ ] Implement -- [ ] Test - -### Task 14: Pending Approvals list + detail pages -- **spec_ref**: `openspec/specs/approval-workflow/spec.md#req-007-pending-approvals-ui` -- **files**: `src/views/Approvals/ApprovalsIndex.vue`, `src/views/Approvals/ApprovalDetail.vue`, router wiring -- **acceptance_criteria**: - - GIVEN an authenticated user with access to at least one approval request WHEN they navigate to the Approvals section THEN the index page renders inside the app-content area with content visible, filterable by `pending`/`approved`/`rejected`/`expired`/`dead_letter` - - GIVEN a `pending` request the current user is authorized to act on WHEN they open its detail page THEN Approve and Reject actions are visible and Reject is disabled until a non-empty comment is entered -- [ ] Implement -- [ ] Test - -### Task 15: Seed data -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/design.md#seed-data` -- **files**: `lib/Settings/register.d/hitl-approval-rule-action.json` (or `integriq_seed_data.json`, per existing seed convention) -- **acceptance_criteria**: - - GIVEN a fresh install WHEN the app boots THEN three `approval_request` seed objects exist (pending/approved/rejected) matching design.md's Seed Data table -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for the `approval_request` state machine (pending → approved/rejected/expired/dead_letter transitions, invalid-transition rejections) in `tests/Unit/Service/ApprovalServiceTest.php` -- [ ] PHPUnit integration test for suspend → approve → resume through a real endpoint rule chain (`tests/Integration/` or equivalent, per existing `rule-pipeline` test conventions) -- [ ] Newman/Postman tests for `/api/approvals*` endpoints (list, detail, approve, reject, 403/404/409 error paths) -- [ ] Browser tests (Playwright MCP) for the Pending Approvals list/detail pages and the Rule editor's new `approval` action form -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` describing the `approval` rule action type, the Synchronization `requiresApproval` gate, and the Pending Approvals UI -- [ ] Screenshot captured and committed to `docs/images/` (Pending Approvals list + detail with Approve/Reject) - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for: the Approvals UI, the `ApprovalForm.vue` rule-editor fields, and the declarative ops-visibility notification's `subject.nl`/`subject.en` +`design.md` and `specs/approval-workflow/spec.md` stay in this directory as +`@spec` anchor targets. There is nothing to implement from this change +directly. diff --git a/openspec/changes/hitl-approval-rule-action/test-plan.md b/openspec/changes/hitl-approval-rule-action/test-plan.md deleted file mode 100644 index cdedeaa13..000000000 --- a/openspec/changes/hitl-approval-rule-action/test-plan.md +++ /dev/null @@ -1,223 +0,0 @@ -# Test Plan: hitl-approval-rule-action - -## Test Cases - -### TC-1: approval rule suspends the pipeline and persists an ApprovalRequest -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-001-endpoint-rule-pipeline-suspension-on-approval-action` -- **type**: api -- **persona**: N/A -- **preconditions**: an endpoint has a `before`-timing `approval` rule at order 20 configured with `approverGroup: "woo-approvers"` -- **steps**: send a request that reaches the pipeline and passes the rule's conditions -- **expected result**: HTTP 202 with `approvalRequestId` + status-polling URL; an `approval_request` object exists with `status: pending`, `resumeOrder: 20`, `approverGroup: "woo-approvers"`, a serialized snapshot, and `expiresAt` -- **test command**: /test-api - -### TC-2: sensitive headers are stripped from the persisted snapshot -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-001-endpoint-rule-pipeline-suspension-on-approval-action` -- **type**: security -- **persona**: N/A -- **preconditions**: as TC-1, request carries an `Authorization` header -- **steps**: trigger suspension -- **expected result**: `approval_request.snapshot` does not contain the `Authorization` header value -- **test command**: /test-security - -### TC-3: after-timing approval rule is rejected as invalid configuration -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-001-endpoint-rule-pipeline-suspension-on-approval-action` -- **type**: api -- **persona**: N/A -- **preconditions**: an `approval` rule configured with `timing: after` -- **steps**: attempt to save/use the rule configuration -- **expected result**: configuration is rejected; no suspension is ever attempted at runtime -- **test command**: /test-api - -### TC-4: approver group receives an actionable notification -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-002-approver-notification-on-suspension` -- **type**: functional -- **persona**: Noor (Municipal CISO / Functional Admin) — verifies the notification and its deep link are trustworthy and actionable -- **preconditions**: an `approval_request` created with `approverGroup: "woo-approvers"`; a test user is a member of that group -- **steps**: log in as the approver-group member, check NC notifications -- **expected result**: a notification with Approve/Reject actions deep-linking to `/apps/integriq/approvals/{id}` is present -- **test command**: /test-functional - -### TC-5: ops group receives a passive visibility notification -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-002-approver-notification-on-suspension` -- **type**: functional -- **persona**: N/A -- **preconditions**: any `approval_request` is created -- **steps**: log in as a member of `openconnector-ops`, check NC notifications -- **expected result**: a non-interactive notification is present, matching the declarative dialect shape used by `openconnector-notifications` -- **test command**: /test-functional - -### TC-6: approval resumes the pipeline with the original context and completes -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-003-resume-on-approval` -- **type**: api -- **persona**: N/A -- **preconditions**: a `pending` `approval_request` at `resumeOrder: 20` for an endpoint whose chain has a `save_object` rule at order 30 -- **steps**: `POST /api/approvals/{id}/approve` as an authorized approver -- **expected result**: the order-30 rule runs against the rehydrated context, the object is persisted, the response carries the resumed pipeline's final result, `status` becomes `approved`, `resumeResult: success` -- **test command**: /test-api - -### TC-7: a resumed chain failure is recorded, not silently swallowed -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-003-resume-on-approval` -- **type**: api -- **persona**: N/A -- **preconditions**: an `approval_request` whose resumed rule chain is configured to throw (e.g. a downstream rule referencing a now-invalid target) -- **steps**: approve the request -- **expected result**: HTTP 500 per the existing rule-pipeline contract; `resumeResult: error` recorded on the `approval_request` -- **test command**: /test-api - -### TC-8: an already-resolved request cannot be approved again -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-003-resume-on-approval` -- **type**: api -- **persona**: N/A -- **preconditions**: an `approval_request` with `status: approved` -- **steps**: `POST /api/approvals/{id}/approve` a second time -- **expected result**: HTTP 409; the chain is not re-run -- **test command**: /test-api - -### TC-9: rejection without a comment is refused -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-004-rejection-with-mandatory-audit-comment` -- **type**: api -- **persona**: N/A -- **preconditions**: a `pending` `approval_request` -- **steps**: `POST /api/approvals/{id}/reject` with an empty comment -- **expected result**: HTTP 400; request remains `pending` -- **test command**: /test-api - -### TC-10: rejection is fully audited -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-004-rejection-with-mandatory-audit-comment` -- **type**: functional -- **persona**: Annemarie (VNG Standards Architect) — verifies audit completeness for compliance review -- **preconditions**: a `pending` `approval_request` configured `onReject: error` -- **steps**: reject with comment "Missing legal basis field" via the Approvals UI -- **expected result**: `status: rejected`, `approverUserId`/`rejectedAt`/comment recorded and visible on the detail page's audit trail; configured error outcome resolves, no further rules run, no target write -- **test command**: /test-persona-annemarie - -### TC-11: expired pending request is swept to its configured fallback -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-005-timeout-sweeping-and-fallback-outcomes` -- **type**: functional -- **persona**: N/A -- **preconditions**: a `pending` `approval_request` with `expiresAt` in the past and `onTimeout: dead_letter` -- **steps**: run `ApprovalTimeoutSweepJob` (via `occ background-job:execute` or cron trigger) -- **expected result**: `status` becomes `dead_letter` -- **test command**: /test-functional - -### TC-12: approving an already-expired request is refused -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-005-timeout-sweeping-and-fallback-outcomes` -- **type**: api -- **persona**: N/A -- **preconditions**: a `pending` `approval_request` with `expiresAt` in the past, sweep job not yet run -- **steps**: `POST /api/approvals/{id}/approve` -- **expected result**: HTTP 409; pipeline not resumed -- **test command**: /test-api - -### TC-13: dead-lettered requests are discoverable, not silently dropped -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-005-timeout-sweeping-and-fallback-outcomes` -- **type**: functional -- **persona**: Noor (Municipal CISO / Functional Admin) -- **preconditions**: at least one `approval_request` with `status: dead_letter` -- **steps**: open the Pending Approvals list, apply the "dead-lettered" filter -- **expected result**: the request appears with its snapshot summary and full audit trail -- **test command**: /test-persona-noor - -### TC-14: unauthorized user cannot approve (approverGroup mismatch) -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-006-two-layer-authorization-for-approvereject` -- **type**: security -- **persona**: N/A -- **preconditions**: a user is in the app-wide `approval.approve` action-matrix group but NOT a member of `"woo-approvers"` -- **steps**: `POST /api/approvals/{id}/approve` -- **expected result**: HTTP 403; `status` remains `pending` -- **test command**: /test-security - -### TC-15: a user outside the action matrix cannot approve even if in the approver group -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-006-two-layer-authorization-for-approvereject` -- **type**: security -- **persona**: N/A -- **preconditions**: a non-admin user is a member of `"woo-approvers"` but not in the `approval.approve` action-matrix group (still default `["admin"]`) -- **steps**: `POST /api/approvals/{id}/approve` -- **expected result**: HTTP 403 -- **test command**: /test-security - -### TC-16: an NC admin may approve regardless of approverGroup membership -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-006-two-layer-authorization-for-approvereject` -- **type**: security -- **persona**: N/A -- **preconditions**: an NC admin not in `"woo-approvers"` -- **steps**: `POST /api/approvals/{id}/approve` -- **expected result**: approval succeeds (admin break-glass) -- **test command**: /test-security - -### TC-17: approvals list page mounts and shows content -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-007-pending-approvals-ui` -- **type**: functional -- **persona**: Mark (MKB Software Vendor) — an integrator configuring/monitoring flows -- **preconditions**: at least one approval request exists the current user can see -- **steps**: navigate to the Approvals section via sidebar and direct URL -- **expected result**: index page renders inside app-content with visible content -- **test command**: /test-persona-mark - -### TC-18: approve/reject actions are available and gated correctly from the detail page -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/approval-workflow/spec.md#req-007-pending-approvals-ui` -- **type**: accessibility -- **persona**: N/A -- **preconditions**: a `pending` request the current user is authorized to act on -- **steps**: open detail page, inspect Approve/Reject controls with a screen reader / keyboard-only navigation -- **expected result**: Approve and Reject are standard NC button components (not icon-only), reachable via keyboard; Reject is disabled until a non-empty comment is entered -- **test command**: /test-accessibility - -### TC-19: a gated synchronization pauses before any writes -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/synchronization-engine/spec.md#req-006-batch-level-approval-gate-before-target-writes` -- **type**: api -- **persona**: N/A -- **preconditions**: a Synchronization with `sourceConfig.requiresApproval: true`, no existing `approval_request` -- **steps**: trigger `synchronize()` (via `run` endpoint or cron) -- **expected result**: an `approval_request` (with `synchronizationId` set) is created, approver group notified, `synchronization_log` records `pending_approval`, zero target objects written or garbage-collected -- **test command**: /test-api - -### TC-20: approval resumes the batch write via a bypass token -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/synchronization-engine/spec.md#req-006-batch-level-approval-gate-before-target-writes` -- **type**: api -- **persona**: N/A -- **preconditions**: TC-19's `approval_request` approved -- **steps**: `ApprovalService::resume()` re-invokes `synchronize(force: true)` with the approved request id -- **expected result**: the gate passes, all fetched/mapped objects are written, `approval_request` marked consumed -- **test command**: /test-api - -### TC-21: an ungated synchronization is unaffected (regression) -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/specs/synchronization-engine/spec.md#req-006-batch-level-approval-gate-before-target-writes` -- **type**: regression -- **persona**: N/A -- **preconditions**: an existing Synchronization with `sourceConfig.requiresApproval` absent or `false` -- **steps**: run the full existing synchronization-engine test suite against it -- **expected result**: write behavior is byte-for-byte unchanged from before this change; no `approval_request` created -- **test command**: /test-regression - -### TC-22: approval rule action type in the Rule editor UI -- **spec_ref**: `openspec/changes/hitl-approval-rule-action/tasks.md#task-13` -- **type**: functional -- **persona**: Priya (ZZP Developer / Integrator) — configures a new rule end-to-end -- **preconditions**: an existing rule object -- **steps**: open the rule detail page, pick action type "Approval", fill in approver group / TTL / onReject / onTimeout -- **expected result**: `ApprovalForm.vue` renders and the saved rule configuration round-trips correctly -- **test command**: /test-persona-priya - -## Coverage Summary - -| Requirement | Covered by | Status | -|---|---|---| -| approval-workflow REQ-001 (suspension) | TC-1, TC-2, TC-3 | covered | -| approval-workflow REQ-002 (notifications) | TC-4, TC-5 | covered | -| approval-workflow REQ-003 (resume) | TC-6, TC-7, TC-8 | covered | -| approval-workflow REQ-004 (rejection audit) | TC-9, TC-10 | covered | -| approval-workflow REQ-005 (timeout sweep) | TC-11, TC-12, TC-13 | covered | -| approval-workflow REQ-006 (two-layer authz) | TC-14, TC-15, TC-16 | covered | -| approval-workflow REQ-007 (Approvals UI) | TC-17, TC-18 | covered | -| rule-pipeline REQ-RULE-008 (approval dispatch) | TC-1, TC-3 (dispatch-level assertions folded into these) | covered | -| synchronization-engine REQ-015 (batch gate) | TC-19, TC-20, TC-21 | covered | -| Rule editor `approval` action form | TC-22 | covered | - -## Out of Scope - -- Talk-message approvals — no test cases (feature is notification-only, per proposal.md Out of Scope). -- Multi-step/quorum approvals — no test cases (single-approver model only). -- Dead-lettered approval replay — no test cases (explicitly deferred, proposal.md Open Questions). -- decidesk integration — no test cases (out of scope for this change). diff --git a/openspec/changes/nc-events-start-or-flows/.openspec.yaml b/openspec/changes/nc-events-start-or-flows/.openspec.yaml new file mode 100644 index 000000000..7b2c0fa7b --- /dev/null +++ b/openspec/changes/nc-events-start-or-flows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-09-02 diff --git a/openspec/changes/nc-events-start-or-flows/proposal.md b/openspec/changes/nc-events-start-or-flows/proposal.md new file mode 100644 index 000000000..3d19a6e12 --- /dev/null +++ b/openspec/changes/nc-events-start-or-flows/proposal.md @@ -0,0 +1,91 @@ +--- +kind: code +depends_on: [integriq-flow-nodes] +--- + +# Proposal: nc-events-start-or-flows + +## Summary + +Let a matched event subscription start an OpenRegister flow run. Integriq's +event hub already normalizes Nextcloud core events (files, calendar, Tables, +Forms) into CloudEvents and lets a subscription drive a synchronization, a +job or a signed webhook. The one engine those events cannot reach is the one +the fleet standardized on: an OpenRegister flow. This change adds `flow` as a +fourth subscription action kind (`event_subscription.action = {kind: "flow", +flowId}`): on match, Integriq asks OpenRegister's `FlowRunService` to run the +named flow with the CloudEvent envelope as input. Nothing else: no scheduler, +no app-local orchestration, no new trigger machinery. + +## Motivation + +One-engine consolidation is retiring the surfaces the current action kinds +point at. `flow-native-synchronization` turns synchronizations into drawn OR +flows and marks Jobs/Rules as legacy; once that lands, "a file changed, so +run this synchronization" is really "a file changed, so run this flow" with +an extra hop through a deprecated surface. Wiring subscriptions to flows +directly means NC events reach the engine that owns automation, and the +legacy kinds can retire without stranding event-driven setups. The +alternative (an OR-side trigger listening for Integriq's CloudEvents) would +put NC-event knowledge into OR; the fleet boundary is the reverse — Integriq +owns the NC event surface and contributes to OR's engine. + +## Affected Projects + +- [x] Project: `integriq` — `event_subscription` schema (`action.kind` enum + + `flowId`), `EventService` dispatch arm, subscription modal picker option, + tests. + +## Scope + +### In Scope + +1. `event_subscription.action.kind` gains `flow`; `action.flowId` names an + OpenRegister flow. Additive schema change to the `event_subscription` + fragment. +2. `EventService`'s action dispatch gains a `flow` arm that resolves + OpenRegister's flow-run entrypoint (duck-typed, ADR-022 style, same as the + other cross-app calls) and starts the flow with the CloudEvent envelope as + the run input. Failure records a delivery failure through the existing + retry/dead-letter path, exactly like the webhook kind. +3. The subscription modal's action-type picker gains "Flow" with an OR flow + picker. +4. Self-service gating is unchanged: the per-family ADR-023 actions govern + who may subscribe; the `flow` kind adds no new grant. + +### Out of Scope + +- Any OR-side change. If `FlowRunService` needs a formal "start with external + input" seam, that is an openregister change this one then consumes. +- Retiring the `synchronization`/`job` kinds (follows + `flow-native-synchronization`'s deprecation track, later change). +- Timer- or schedule-shaped triggering: OR's `TriggerScheduleNode` (with + explicit `runAs`) owns that; an event subscription is never a scheduler. + +## Approach + +One additive enum value, one dispatch arm, one picker option. The dispatch +arm mirrors the existing `synchronization`/`job` arms' shape and error +handling. Sequenced after `integriq-flow-nodes` so a triggered flow has +call/sync nodes worth running. + +## Impact + +- `lib/Settings/register.d/` — `event_subscription` fragment: `action.kind` + enum + `flowId`. +- `lib/Service/EventService.php` — one dispatch arm. +- `src/` subscription modal — picker option + flow picker. +- Unit tests for the dispatch arm; Playwright for the picker. + +## Rollback Strategy + +Additive on every surface. Revert the PR; subscriptions with `kind: flow` +become non-matching rows that the dispatch logs as unknown-kind failures, +and deleting them is a data cleanup, not a migration. + +## Open Questions + +- Which OR entrypoint is the stable one for "run this flow now with this + input" — `FlowRunService` directly, or a queued start? Resolve against + OpenRegister at HEAD when implementation starts; prefer whatever + `flow-native-synchronization` already binds to. diff --git a/openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md b/openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md new file mode 100644 index 000000000..50c6479cf --- /dev/null +++ b/openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md @@ -0,0 +1,39 @@ +# nextcloud-event-triggers Specification (delta) + +## ADDED Requirements + +### Requirement: A matched subscription can start an OpenRegister flow + +`event_subscription.action.kind` MUST accept `flow`, with `action.flowId` +naming an OpenRegister flow. On match, Integriq MUST start that flow through +OpenRegister's flow-run entrypoint with the CloudEvent envelope as the run +input, and MUST record a start failure through the existing delivery +failure/retry path. + +@e2e exclude backend dispatch into OpenRegister's flow engine — covered by +PHPUnit on the dispatch arm plus the flow engine's own run coverage; the only +browser surface is the picker below. + +#### Scenario: A file event starts a flow +- GIVEN a subscription for `com.nextcloud.files.node.created` with `action: {kind: "flow", flowId: F}` +- WHEN a matching CloudEvent is processed +- THEN flow F is started with the event envelope as input + +#### Scenario: A failed start dead-letters like any delivery +- GIVEN the flow-run entrypoint throws +- WHEN the subscription fires +- THEN a delivery failure is recorded and retried per the subscription's retry policy + +### Requirement: The subscription modal offers the flow action kind + +The action-type picker MUST offer `flow` alongside synchronization, job and +webhook, with an OpenRegister flow picker for `flowId`. + +@e2e exclude picker lands with the implementation change; the Playwright spec +extends `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` authored in +`nextcloud-event-hub-verification`, which MUST land first. + +#### Scenario: Choosing flow persists the target +- GIVEN the subscription modal +- WHEN "Flow" is chosen and a flow is picked +- THEN the saved subscription carries `action: {kind: "flow", flowId}` diff --git a/openspec/changes/nc-events-start-or-flows/tasks.md b/openspec/changes/nc-events-start-or-flows/tasks.md new file mode 100644 index 000000000..29716d25a --- /dev/null +++ b/openspec/changes/nc-events-start-or-flows/tasks.md @@ -0,0 +1,52 @@ +# Tasks: nc-events-start-or-flows + +Gate: `integriq-flow-nodes` must be landed first, so a triggered flow has +call/synchronization nodes worth running. `nextcloud-event-hub-verification` +must land first too: its Playwright spec file is the one task 4 extends. + +## 1. Schema + +### Task 1: `flow` action kind on `event_subscription` +- **spec_ref**: `openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md` +- **files**: `lib/Settings/register.d/` (event_subscription fragment) +- **acceptance_criteria**: + - GIVEN the merged register THEN `action.kind` accepts `flow` and `action.flowId` (string) exists; existing kinds and required fields are untouched +- [ ] Implement +- [ ] Test + +## 2. Dispatch + +### Task 2: `flow` arm in EventService's action dispatch +- **spec_ref**: `openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md` +- **files**: `lib/Service/EventService.php` +- **acceptance_criteria**: + - GIVEN a matched subscription with `kind: flow` THEN OpenRegister's flow-run entrypoint is called with the CloudEvent envelope as input (duck-typed resolution, ADR-022) + - GIVEN the entrypoint throws THEN the existing delivery failure/retry path records it, mirroring the webhook kind +- [ ] Implement +- [ ] Test + +## 3. UI + +### Task 3: "Flow" in the action-type picker +- **spec_ref**: `openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md` +- **files**: subscription modal component under `src/modals/` +- **acceptance_criteria**: + - GIVEN the modal WHEN "Flow" is chosen THEN an OR flow picker (NcSelect with `inputLabel`) renders and the saved subscription carries the chosen `flowId` +- [ ] Implement +- [ ] Test + +### Task 4: Playwright coverage +- **spec_ref**: `openspec/changes/nc-events-start-or-flows/specs/nextcloud-event-triggers/spec.md` +- **files**: `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` +- **acceptance_criteria**: + - GIVEN the spec runs THEN choosing "Flow", picking a flow and saving round-trips, traced to the picker scenario +- [ ] Implement +- [ ] Test + +## Verification +- [ ] All tasks checked off +- [ ] Manual testing against acceptance criteria +- [ ] Code review against spec requirements + +## Tests (company-wide ADR-009) +- [ ] All tests pass (`composer test`, Playwright suite) diff --git a/openspec/changes/nextcloud-event-hub-verification/.openspec.yaml b/openspec/changes/nextcloud-event-hub-verification/.openspec.yaml new file mode 100644 index 000000000..7b2c0fa7b --- /dev/null +++ b/openspec/changes/nextcloud-event-hub-verification/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-09-02 diff --git a/openspec/changes/nextcloud-event-hub-verification/proposal.md b/openspec/changes/nextcloud-event-hub-verification/proposal.md new file mode 100644 index 000000000..f2adf0109 --- /dev/null +++ b/openspec/changes/nextcloud-event-hub-verification/proposal.md @@ -0,0 +1,82 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: nextcloud-event-hub-verification + +## Summary + +Close the verification debt the archived `nextcloud-event-hub` change left +open with honest unticked boxes. The most urgent item is not a test but a +fact-check: the Tables and Forms listeners shipped against event class names +the discovery doc itself called "plausible/unverified". An `IEventListener` +registered for a class that does not exist is a silent no-op — no error, no +delivery, nothing logged — so two of the five listener families may not work +at all and nothing would tell us. The rest is the standard pack: Playwright +for the self-service grant flow, Newman for the new subscription fields and +their 403 paths, and feature docs. + +## Motivation + +The archived twin (`archive/2026-07-15-nextcloud-event-hub`) checked 30/43 +boxes; the open ones name exactly this work. It has sat invisible inside a +superseded 0/43 umbrella since 2026-07-15. The class-name spike is the +one item that can invalidate shipped behavior, which is why it is task 1 and +everything Tables/Forms-shaped is sequenced behind it. + +## Affected Projects + +- [x] Project: `integriq` — a live-instance spike (with a listener fix if the + spike finds wrong class names), tests, docs. No intentional behavior + changes. + +## Scope + +### In Scope + +1. Spike: on a live instance with Tables and Forms enabled, trigger a row + create/update/delete and a form submission and confirm the exact event + classes dispatched against what `NextcloudTablesEventListener` / + `NextcloudFormsEventListener` register for. If they differ, fix the + listener registrations (that fix is in scope: it is the difference between + the shipped feature existing and not existing). +2. Playwright: `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` — + admin grants an event family via the existing ActionAuthMatrix editor, a + non-admin then self-service-subscribes to that family and cannot subscribe + to an ungranted one; the subscription modal's action-type picker + (synchronization | job | webhook) persists its choice. +3. Newman: subscribe with `action` and `retryPolicy`, per-family 403 for + ungranted non-admins, and the delivery-status read path. +4. Feature docs: a "Nextcloud event triggers" page (file/calendar/Tables/ + Forms families, filtering, actions, retry policy, self-service gating) + plus a screenshot of the matrix editor showing the event families. + +### Out of Scope + +- New event families, new action kinds, or filter dialect changes. +- Starting OR flows from events: `nc-events-start-or-flows`. +- Outbound delivery scope: the ADR-041 seam (#1810) and + `absorb-dossiq-deliveries`. + +## Approach + +Spike first, against the dev environment with Tables/Forms enabled; record +the observed class names in this change's tasks as evidence. Then tests and +docs in any order. + +## Impact + +- `lib/EventListener/NextcloudTablesEventListener.php`, + `lib/EventListener/NextcloudFormsEventListener.php`, + `lib/AppInfo/Application.php` — only if the spike finds wrong class names. +- `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` — new. +- `tests/postman/` — scenarios added. +- `docs/` + `docs/images/` — new page + screenshot. + +## Rollback Strategy + +Tests and docs revert cleanly. A listener class-name fix, if any, is a +two-line registration change; reverting it restores the prior (broken) +registration, so it would only be reverted together with evidence the spike +was wrong. diff --git a/openspec/changes/nextcloud-event-hub-verification/tasks.md b/openspec/changes/nextcloud-event-hub-verification/tasks.md new file mode 100644 index 000000000..10b14eddd --- /dev/null +++ b/openspec/changes/nextcloud-event-hub-verification/tasks.md @@ -0,0 +1,59 @@ +# Tasks: nextcloud-event-hub-verification + +## 1. The spike (first — it can invalidate shipped behavior) + +### Task 1: Confirm Tables/Forms event class names on a live instance +- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md` +- **files**: none (evidence recorded here), or `lib/EventListener/NextcloudTablesEventListener.php`, `lib/EventListener/NextcloudFormsEventListener.php`, `lib/AppInfo/Application.php` if wrong +- **acceptance_criteria**: + - GIVEN a live instance with Tables and Forms enabled WHEN a row is created/updated/deleted and a form is submitted THEN the dispatched event classes are recorded here verbatim + - GIVEN a mismatch with what the listeners register for THEN the registrations are corrected and a unit test pins each corrected class name +- [ ] Implement +- [ ] Test + +## 2. Playwright + +### Task 2: Family grant + self-service subscribe flow +- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md` +- **files**: `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` +- **acceptance_criteria**: + - GIVEN an admin grants `event.subscribe-nextcloud-files` to a group via the ActionAuthMatrix editor WHEN a member self-service-subscribes to a file event THEN it succeeds + - GIVEN an ungranted family WHEN the same user tries THEN the subscription is refused +- [ ] Implement +- [ ] Test + +### Task 3: Action-type picker in the subscription modal +- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md` +- **files**: `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` +- **acceptance_criteria**: + - GIVEN the subscription modal WHEN synchronization, job or webhook is chosen THEN the matching target picker renders and the saved subscription carries the chosen `action` +- [ ] Implement +- [ ] Test + +## 3. Newman + +### Task 4: Subscription API scenarios +- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md` +- **files**: `tests/postman/` (existing collection) +- **acceptance_criteria**: + - GIVEN the collection runs THEN subscribe with `action` and `retryPolicy` round-trips, an ungranted non-admin gets `403` per family, and delivery status is readable +- [ ] Implement +- [ ] Test + +## 4. Docs + +### Task 5: "Nextcloud event triggers" documentation page +- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md` +- **files**: `docs/`, `docs/images/` +- **acceptance_criteria**: + - GIVEN `docs/` THEN a page covers the four event families, filtering, the three action kinds, retry policy and self-service gating, with a screenshot of the matrix editor's event-family rows +- [ ] Implement +- [ ] Test + +## Verification +- [ ] All tasks checked off +- [ ] Manual testing against acceptance criteria +- [ ] Code review against spec requirements + +## Tests (company-wide ADR-009) +- [ ] All tests pass (`composer test`, `newman run`, Playwright suite) diff --git a/openspec/changes/nextcloud-event-hub/context-brief.md b/openspec/changes/nextcloud-event-hub/context-brief.md deleted file mode 100644 index 5a433e11e..000000000 --- a/openspec/changes/nextcloud-event-hub/context-brief.md +++ /dev/null @@ -1,19 +0,0 @@ -# Context Brief: nextcloud-event-hub -Source: Specter deep-research 2026-07-14 (insight #1250). VERIFY every code claim against HEAD before writing artifacts. - -## Problem / Opportunity (differentiator) -Nextcloud core webhook_listeners (NC 30+) exposes events (file/folder CRUD+tags, calendar object CRUD, Forms submissions, Tables row add/update/delete, custom app events) but: registration is admin-only via OCS/CLI (no UI), delivery is background-job based (up to 5 min latency), and there are NO documented retries, HMAC signing, dead-lettering, or delivery guarantees. Nextcloud's own orchestration answer (Windmill) requires operating a second product. No App Store app fills this. Integriq already has: CloudEvents emit/consume, Event/EventSubscription/Consumer entities, dead-letter capture + one-click replay (EventDeliveries UI, EventRetryJob), WebhookSignatureService, and OR object lifecycle listeners. - -## In scope -1. Nextcloud event triggers: subscribe Integriq to NC core events IN-PROCESS (PHP event listeners — no HTTP hop, no webhook_listeners dependency): file created/updated/deleted/tagged, calendar object CRUD, Tables row changes, Forms submission (verify which events are exposed as OCP events on NC 28-34; gate per-event-type availability). Selected events become Event entities that can trigger: a synchronization, a job, or an outbound webhook — configured via a new "Nextcloud event" trigger type on EventSubscription. -2. Event filter: JsonLogic condition on event payload (reuse existing JsonLogic machinery). -3. Guaranteed outbound webhook delivery: per-consumer retry policy w/ exponential backoff, HMAC signing (wire existing WebhookSignatureService into subscription delivery), delivery log, dead-letter + replay (reuse EventDeliveries), delivery-status UI per subscription. -4. Self-service: non-admin users (via action authorization matrix ADR-023) can create subscriptions for event types admins have allowed. -5. Tests: unit for filter/backoff; integration: fire NC event → assert delivery record + signature header; failure path → DLQ + replay. -## Out of scope -- Kafka/MQTT (deferred), Flow/WorkflowEngine actions (possible follow-up change), Tables as sync source/target (tables-bridge change). - -## Constraints -- Reuse events-cloudevents, dead-letter-replay, webhook-signing, consumer-management specs — write deltas, don't fork machinery. -- NC version gating: Tables/Forms events only when apps installed; feature-detect, no hard dependency. -- Register schema updates via openconnector-register-schema descriptor. diff --git a/openspec/changes/nextcloud-event-hub/design.md b/openspec/changes/nextcloud-event-hub/design.md deleted file mode 100644 index 1c8e71b76..000000000 --- a/openspec/changes/nextcloud-event-hub/design.md +++ /dev/null @@ -1,308 +0,0 @@ -# Design: nextcloud-event-hub - -## Architecture Overview -Today: NC core → nothing (Integriq has no listeners) or (separately) `webhook_listeners` core app → -polled background job → unsigned, unretried HTTP POST. OR object writes → `ObjectCreatedEvent` etc. → -`lib/EventListener/Object*EventListener.php` → `EventService::handleObjectCreated/Updated/Deleted` → -persists an `event` OR-object in the CloudEvents envelope → `EventService::processEvent` → matches -`event_subscription`s → persists `event_message` → `deliverMessage` (signs via `WebhookSignatureService`, -retries via `EventRetryJob` sweep, dead-letters via `EventsController` replay/discard endpoints). - -This change adds a second producer path that lands in the exact same pipe: - -``` -NC core event (OCP\Files\Events\Node\*, OCA\DAV\Events\*, OCA\Tables\Event\*, OCA\Forms\Event\*) - → new lib/EventListener/Nextcloud*EventListener.php (IEventListener, addServiceListener in Application.php) - → EventService::handleNextcloudEvent(string $ncEventType, array $normalizedPayload) [NEW] - persists an `event` OR-object: type = com.nextcloud..., - source = /nextcloud/, subject = node/object id, data = normalized payload - → EventService::processEvent(...) [UNCHANGED] - doesEventMatchSubscription / evaluateFilters (+ new `jsonlogic` dialect) [EXTENDED] - → per matched subscription, NEW action dispatch inside processEvent: - action.kind === 'webhook' (default, back-compat) → deliverMessage (UNCHANGED, incl. signing) - action.kind === 'synchronization' → SynchronizationService::synchronize() [NEW call site] - action.kind === 'job' → JobService::executeJob() [NEW call site] - → event_message persisted regardless of action.kind, so dead-letter/replay/audit-trail - (attempts[], status machine) applies uniformly — a failed synchronization/job run is recorded - the same way a failed webhook POST is. -``` - -No new services, no new controllers for the producer side. `EventsController` gains the self-service -authorization gate on the existing `subscribe()`/`updateSubscription()` methods (REQ-005), not a new -endpoint family. - -## API Design - -### `POST /api/events/subscriptions` (existing endpoint, extended request body) -**Request:** -```json -{ - "types": ["com.nextcloud.files.node.created"], - "filters": [{"jsonlogic": {"in": [".pdf", {"var": "data.attributes.name"}]}}], - "action": {"kind": "synchronization", "synchronizationId": "b6b1...-uuid"}, - "retryPolicy": {"baseSeconds": 30, "factor": 3, "capSeconds": 3600, "maxRetries": 8} -} -``` -**Response (200):** unchanged shape (serialised `event_subscription`), now including `action`/`retryPolicy`. - -**Errors (new):** -| Code | Condition | -|------|-----------| -| 403 | Caller is non-admin and the requested `types` include an NC event type whose per-family action (`event.subscribe-nextcloud-`) is not granted to any of their NC groups in the ADR-023 action matrix (`ActionAuthService::requireAction` throws `OCSForbiddenException`) | -| 400 | `action.kind` is `synchronization`/`job` but the referenced id does not resolve to an existing `synchronization`/`job` object | - -No new settings endpoint. The self-service allow list IS the existing ADR-023 action matrix: admins grant -the four new `event.subscribe-nextcloud-*` actions to NC groups via the pre-existing -`GET`/`PUT /api/admin/action-matrix` endpoints (`ActionMatrixController`, `#[AuthorizedAdminSetting]`) and -the pre-existing `src/views/admin/ActionAuthMatrix.vue` editor — `ActionMatrixController::getMatrix()` -already unions `lib/actions.seed.json` keys into its response, so the new seeded actions appear in the -admin UI with zero UI changes. - -## Database Changes -No new Nextcloud migration / SQL table. All new fields (`action`, `retryPolicy`) are additive properties -on the existing `event_subscription` OR-managed schema in `lib/Settings/integriq_register.json` — OR -schemas are schema-less at the storage layer (JSON column), so no `lib/Migration/VersionXXXXXXXXXX.php` is -required; see `migration.md` for the register-descriptor-only migration note. The self-service allow list -lives in the existing ADR-023 action matrix (`IAppConfig` key `actions`, managed by `ActionAuthService` — -see Decision 5), which needs no schema/migration either; the four new actions are seeded via the existing -`lib/Repair/InitializeActions.php` mechanism from `lib/actions.seed.json`. - -## Nextcloud Integration -- **Controllers:** `EventsController` (existing; `subscribe`/`updateSubscription` extended with per-family - `requireAction` calls, layered on the coarse `event.subscribe`/`event.update-subscription` actions those - methods ALREADY enforce at HEAD). `ActionMatrixController` (existing, unchanged) remains the admin - read/write surface for the matrix. -- **Services:** `EventService` (extended — see Decisions). `ActionAuthService` (existing, unchanged — - ADR-023 action RBAC, `lib/Service/ActionAuthService.php`) is reused as-is; NO new authorization service - is introduced. -- **Mappers/Entities:** none — OR `ObjectEntity` generic storage, unchanged pattern. -- **Events/Hooks:** 4 new `IEventListener` classes under `lib/EventListener/`, registered in - `Application.php::register()` via `IEventDispatcher::addServiceListener`, mirroring the existing - `ObjectCreatedEventListener` registration exactly (Decision 1). - -## Security Considerations -- **Layered ADR-023 gating, per-object ownership still open:** at HEAD, every `EventsController` method - already enforces a coarse ADR-023 action (`event.subscribe`, `event.update-subscription`, …) via - `ActionAuthService::requireAction`. This change layers per-event-family actions on top for NC-native - types only. What ADR-023 action RBAC deliberately does NOT provide is per-object ownership checks — a - non-admin granted `event.update-subscription` can still update ANY subscription by UUID (the residual - per-object gap noted in the `events-cloudevents` spec's REQ-005 Notes). Closing that ownership gap is out - of this change's scope and would be a separate, security-labelled change. -- **Default-deny:** every new `event.subscribe-nextcloud-*` action is seeded `["admin"]` in - `lib/actions.seed.json`, and `ActionAuthService::getAllowedGroups` falls back to `["admin"]` for any - action missing from the matrix — so NC-native self-service is admin-only on fresh install AND on - upgraded installs whose matrix predates the new seed entries, matching ADR-023's first-install-safe - posture by construction. -- **JsonLogic filters:** unlike the existing `expression` dialect (Symfony ExpressionLanguage — Turing-complete, - already flagged as a security concern in the events-cloudevents spec Notes), `jwadhams/json-logic-php` - exposes a fixed, non-Turing-complete operator set with no code-execution primitives. Still evaluates - subscriber-supplied logic against the full event payload, so `data.attributes` on file/calendar events - MUST NOT be assumed access-controlled at filter-evaluation time — the filter only decides delivery, it is - not a substitute for the action-matrix gate. -- **OCA event payload trust:** `OCA\DAV\Events\*`/`OCA\Tables\Event\*`/`OCA\Forms\Event\*` payloads are - passed through into `event.data` verbatim (after normalization) exactly like OR object attributes today — - no new trust boundary is crossed, but node/file paths now flow into a system (subscriptions/webhooks) that - a non-admin (self-service) user may have configured, so path/name data reaching a subscriber's own webhook - MUST be limited to nodes that user can already access. See Decision 6. -- **Signing / CSRF / rate-limit:** unchanged — this change delivers through the existing signed, retried, - rate-limited (`consumer-management`) pipeline and inherits its posture, including its known gaps (flagged - in `webhook-signing`/`consumer-management` specs, not re-litigated here). - -## NL Design System -The self-service subscription modal additions reuse existing NcSelect/NcCheckboxRadioSwitch components -already used by `EventDeliveryDetailModal.vue` and the Consumers editor; the admin allow-list surface is the -pre-existing `ActionAuthMatrix.vue` editor, entirely unchanged. No new component patterns. WCAG AA unchanged -(existing app baseline). - -## File Structure -``` -lib/ - AppInfo/ - Application.php # + 4 addServiceListener calls, feature-detected - EventListener/ - NextcloudFileEventListener.php # NEW — OCP\Files\Events\Node\* (created/written/deleted) - NextcloudCalendarEventListener.php # NEW — OCA\DAV\Events\CachedCalendarObject*Event - NextcloudTablesEventListener.php # NEW — OCA\Tables\Event\Row*Event (feature-detected) - NextcloudFormsEventListener.php # NEW — OCA\Forms\Event\FormSubmitted (feature-detected) - Service/ - EventService.php # + handleNextcloudEvent, action dispatch, jsonlogic dialect, - # retryPolicy-aware deliverMessage/recordFailure - ActionAuthService.php # EXISTING, unchanged — reused for the self-service gate - Controller/ - EventsController.php # subscribe()/updateSubscription(): + per-family requireAction - # calls for NC-native types (layered on existing coarse actions) - ActionMatrixController.php # EXISTING, unchanged — admin matrix read/write surface - Repair/ - InitializeActions.php # EXISTING, unchanged — seeds new actions from actions.seed.json - actions.seed.json # + 4 new event.subscribe-nextcloud-* action entries (["admin"]) - Settings/ - integriq_register.json # event_subscription: + action, retryPolicy fields -src/ - views/EventDelivery/EventDeliveriesPage.vue # + NC-native event type filter/badge - modals/EventDelivery/EventDeliveryDetailModal.vue # + action.kind display (webhook/sync/job) - modals/EventSubscription/SubscriptionActionFields.vue # NEW — action.kind picker (own modal-adjacent file) - views/admin/ActionAuthMatrix.vue # EXISTING, unchanged — new seeded actions appear automatically -tests/ - Unit/Service/EventServiceNextcloudEventTest.php # NEW - Unit/Service/JsonLogicFilterDialectTest.php # NEW - Integration/NextcloudEventDeliveryTest.php # NEW -``` - -## Seed Data - -### Schema: `event_subscription` (additive fields on existing seed objects — no new schema) -| Field | Existing seed 1 (unchanged) | New seed: file-triggered sync | New seed: calendar-triggered webhook | -|-------|------------------------------|--------------------------------|----------------------------------------| -| slug | (unchanged) | `seed-nc-file-tagged-sync` | `seed-nc-calendar-webhook` | -| types | (unchanged) | `["com.nextcloud.files.node.tagged"]` | `["com.nextcloud.calendar.object.created"]` | -| filters | (unchanged) | `[{"jsonlogic": {"in": ["invoice", {"var": "data.attributes.tags"}]}}]` | `[]` | -| action | (absent → webhook default) | `{"kind": "synchronization", "synchronizationId": ""}` | `{"kind": "webhook"}` | -| sink | (unchanged) | — | `https://example.org/hooks/calendar` (placeholder) | -| retryPolicy | (absent → defaults) | (absent → defaults) | `{"baseSeconds": 30, "factor": 2, "capSeconds": 1800, "maxRetries": 3}` | - -**Related items per object:** none (subscriptions are configuration objects, not linked to files/notes/tasks). - -## Trade-offs -See Decisions below for alternatives considered per decision; the overarching trade-off is **extend one -mature pipeline** (chosen) vs. **build a parallel "NC event" pipeline with its own subscription/delivery -model** (rejected — would duplicate signing, retry, dead-letter, and UI work that already exists and is -already spec'd, directly contradicting the proposal's reuse constraint). - -## Decisions - -### Decision 1: Listener registration — eager `addServiceListener` in `Application.php::register()`, not lazy/deferred -**Chosen:** Follow the exact existing idiom (`$dispatcher->addServiceListener(eventName: X::class, className: -Y::class)`), called unconditionally for `OCP\Files\Events\Node\*` (stable OCP, always safe) and, for -Tables/Forms, wrapped in a feature-detection guard using `IAppManager::isEnabledForAnyUser('tables'|'forms')` -evaluated once at boot. -**Why:** `X::class` is a compile-time string constant — referencing `OCA\Tables\Event\RowAddedEvent::class` -does not trigger autoloading and is safe even when the `tables` app is absent (confirmed: no runtime error -from an unresolvable class-string used only as an event-name key). The listener itself is never invoked -because Tables never dispatches that event name when the app isn't installed. The feature-detection guard is -therefore not required for safety, but IS useful as an "is this event family available" signal (e.g. for -the subscription modal's event-type picker to grey out Tables/Forms types when the source app is absent), -so it is added for UX/observability, not correctness. -**Alternative considered — lazy registration on first subscription:** rejected. Registering listeners only -when a subscription referencing that event type exists would require re-registering on Application boot -after every subscription change (NC re-bootstraps the app container per request in some SAPIs), adds a -stateful dependency between subscription CRUD and listener wiring, and every other listener in this app -registers eagerly — deviating here for no correctness benefit adds cognitive cost for a marginal, unmeasured -performance saving (NC event listener registration is O(1) string-map inserts, not I/O). - -### Decision 2: Event normalization shape — same CloudEvents envelope as OR object events, new type namespace -**Chosen:** NC-native events reuse the exact `event` OR-object shape `handleObjectCreated`/`Updated`/`Deleted` -already write (`source`, `type`, `time`, `subject`, `data.type`+`data.id`+`data.attributes`), under a new -`com.nextcloud...` type namespace (e.g. `com.nextcloud.files.node.created`, -`com.nextcloud.calendar.object.updated`, `com.nextcloud.tables.row.deleted`, -`com.nextcloud.forms.submission.created`) and `source = /nextcloud/` (paralleling the existing -`/objects/` convention for OR events). -**Why:** `processEvent`, `evaluateFilters`, `deliverMessage`, the retry sweep, the dead-letter UI, and the -signing service all operate on the `event`/`event_message` shape and do not care what produced it — reusing -the shape is what makes "reuse the machinery, don't fork it" (the proposal's explicit constraint) actually -true rather than aspirational. A distinct shape would require either a second `evaluateFilters`/`deliverMessage` -implementation or a translation layer, both strictly worse. -**Alternative considered — a dedicated "NC event" envelope distinct from CloudEvents:** rejected; this is -what the brief's framing ("CloudEvents envelope for internal NC events too") explicitly asks to avoid, and -verification confirmed OR object events already establish the precedent of using this same envelope for -non-webhook-sourced, in-process-originated events. - -### Decision 3: Retry/backoff schedule storage — per-subscription `retryPolicy`, class constants as default -**Chosen:** Add `event_subscription.retryPolicy = {baseSeconds, factor, capSeconds, maxRetries}` (all -optional). `EventService::recordFailure`/`deliverMessage` read `subscription.retryPolicy. ?? -self::RETRY_BASE_SECONDS` etc., preserving today's exact behaviour (60s / ×4 / 6h cap / 5 retries) for every -subscription that doesn't set it — including all existing OR-object subscriptions, satisfying the -non-regression requirement. -**Why:** The brief calls for "per-consumer retry policy w/ exponential backoff" but `sink`/delivery -configuration already lives on `event_subscription`, not `consumer` (a subscription need not reference a -`consumer` at all — `protocolSettings.signingSecret` is already subscription-scoped per the `webhook-signing` -spec). Placing `retryPolicy` at the same grain as `signingSecret` keeps delivery-tuning config in one place. -**Alternative considered — global-only backoff (status quo, no per-subscription override):** rejected — the -brief explicitly asks for it, and a self-service subscriber to a high-value sync target may reasonably want -faster/more retries than a low-value webhook notification. -**Alternative considered — storing retry policy on `consumer`:** rejected — a `pull`-style subscription or a -subscription with no `consumer` reference would have nowhere to read it from; `event_subscription` is the -grain every delivery attempt is actually keyed on. - -### Decision 4: Action dispatch — new `action` field on `event_subscription`, `webhook` as implicit default -**Chosen:** `event_subscription.action = {kind: 'webhook'|'synchronization'|'job', sink?, synchronizationId?, -jobId?}`. When `action` is absent, behaviour is `kind: 'webhook'` using the existing top-level `sink` field — -100% backward compatible with every existing subscription. `processEvent` branches on `action.kind` right -before the existing `deliverMessage` call: `synchronization` calls -`SynchronizationService::synchronize($synchronizationObject)`, `job` calls -`JobService::executeJob($jobObject, forceRun: true)`, both wrapped in the same try/log/persist-`event_message` -pattern `deliverMessage` already uses, so success/failure still lands in `attempts[]`/`status` and is subject -to the SAME retry sweep and dead-letter/replay UI — a failed synchronization run is replay-able exactly like -a failed webhook POST. -**Why:** Keeps exactly one `event_message` status machine and one dead-letter/replay surface for all three -action kinds, per the proposal's reuse constraint, rather than inventing a second "job run history"/"sync run -history" concept that duplicates what `job_log`/`synchronization_log` already track. -**Alternative considered — three separate subscription types (new schemas):** rejected — triples the schema -surface, triples the controller/UI surface, and loses the shared filter/retry/dead-letter machinery this -whole change exists to reuse. - -### Decision 5: Self-service allow list — reuse the existing ADR-023 action matrix (`ActionAuthService`), no new mechanism -**Chosen:** The self-service gate is four new ADR-023 actions seeded in `lib/actions.seed.json` following -the existing `.` convention (cf. `event.subscribe`, `synchronization.run`): - -- `event.subscribe-nextcloud-files` -- `event.subscribe-nextcloud-calendar` -- `event.subscribe-nextcloud-tables` -- `event.subscribe-nextcloud-forms` - -all seeded `["admin"]` (default-deny for non-admins). Enforcement reuses `ActionAuthService::requireAction` -(`lib/Service/ActionAuthService.php` — admin always passes; throws `OCSForbiddenException` otherwise); -matrix storage stays in `IAppConfig` key `actions`; seeding on install/upgrade via the existing -`lib/Repair/InitializeActions.php` (which merges seed keys without overwriting admin customizations); -admin editing via the existing `ActionMatrixController` (`GET`/`PUT /api/admin/action-matrix`) and -`src/views/admin/ActionAuthMatrix.vue` — whose `getMatrix()` response already unions seed-file keys, so the -new actions surface in the UI with zero new endpoint/UI/service code. -**Why:** Integriq already ships a complete ADR-023 implementation at HEAD — `ActionAuthService` with -`requireAction()`/`can()`/`getMatrix()`/`setMatrix()`, used by eight controllers including -`EventsController` itself (every events endpoint already enforces a coarse action such as -`event.subscribe`). Introducing a parallel allow-list mechanism (a `NextcloudEventAuthorizationService` + -dedicated settings endpoint + dedicated editor view, as an earlier draft of this design proposed) would -duplicate authorization infrastructure that exists, is seeded, is admin-editable, and is already enforced on -the exact controller methods this change extends — a direct ADR-011 violation (reuse before reimplement). -**Granularity note:** the actions are per event FAMILY (files/calendar/tables/forms), not per individual -event type (11 types). Per-family keeps the matrix legible (4 rows vs 11 alongside the existing ~38) and -matches the trust boundary that actually matters (which NC domain's data a group may subscribe to); -finer per-type control remains available later by seeding additional actions without any mechanism change. -**Alternative considered — bespoke `IAppConfig` allow-list keyed by event type with its own service, -endpoint, and settings view:** rejected — duplicates the existing ADR-023 machinery. -**Alternative considered — OR-managed schema (`nextcloud_event_allowlist` register entry):** rejected — -over-engineering for admin-only, low-cardinality config, and equally duplicative of the action matrix. - -### Decision 6: Non-admin subscribe() gate — per-family actions layered on the existing coarse actions, scoped strictly to NC-native `types` -**Chosen:** `EventsController::subscribe()`/`updateSubscription()` ALREADY call -`ActionAuthService::requireAction` with the coarse `event.subscribe`/`event.update-subscription` actions at -HEAD — that stays untouched and continues to gate the endpoint as a whole. This change ADDS, after the -coarse check, one `requireAction($user, 'event.subscribe-nextcloud-')` call per distinct NC-native -domain present in the request's `types[]` — where a type maps to a domain via -`com.nextcloud..*` for `` ∈ {files, calendar, tables, forms}, explicitly EXCLUDING -`com.nextcloud.openregister.*` (the pre-existing OR-object namespace, which happens to share the same -`com.nextcloud.` top-level reverse-DNS root). Requests whose `types` are exclusively -`com.nextcloud.openregister.object.*` (or any other non-NC-native type) trigger no per-family check — -unchanged from today. This exclusion is load-bearing, not cosmetic: both namespaces share the -`com.nextcloud.` prefix, so a naive prefix→domain mapping would incorrectly gate every existing OR-object -subscription request too. Note `requireAction`'s admin bypass is built in, so admins are never gated. -**Why (layering):** two actions with different scopes compose naturally in the matrix — an admin grants a -group `event.subscribe` (may create subscriptions at all) plus `event.subscribe-nextcloud-files` (may -scope them to file events); either grant alone is insufficient for NC-native self-service, which is exactly -the least-privilege posture ADR-023 intends. -**Why (scoping to NC-native only):** changing the authorization posture of the pre-existing OR-event -subscribe path is a distinct, security-classified change of its own — in particular the residual PER-OBJECT -ownership gap (any holder of `event.update-subscription` can update ANY subscription by UUID; action RBAC -by design does not check object ownership) is out of scope per the proposal's Risk 2 mitigation. -**Alternative considered — also adding per-object ownership checks while in the code:** tempting, but -rejected for this change; tracked instead as a candidate for a dedicated security-labelled follow-up change -against `events-cloudevents` REQ-005, so it gets its own risk review and its own test plan rather than being -an implicit side effect here. - -## Migration Plan -No schema migration. See `migration.md` for the register-descriptor-only change record and the app -install/upgrade sequencing note (new listeners register on next app boot; no backfill of historical NC -events is possible or attempted — this is a forward-only, event-driven feature). - -## Open Questions -See `proposal.md` Open Questions — one carried forward (Forms/Tables event class name verification). The -earlier allow-list-storage question is resolved by Decision 5 (reuse the existing ADR-023 action matrix). diff --git a/openspec/changes/nextcloud-event-hub/discovery.md b/openspec/changes/nextcloud-event-hub/discovery.md deleted file mode 100644 index 2570a8e08..000000000 --- a/openspec/changes/nextcloud-event-hub/discovery.md +++ /dev/null @@ -1,107 +0,0 @@ -# Discovery: nextcloud-event-hub - -## Question -Which Nextcloud core/bundled-app event classes for files, calendar, Tables, and Forms actually exist and -are stable enough to build in-process `IEventListener`s against, across the NC 28–34 range this app -targets — and does Integriq already have any of the "reuse" machinery (JsonLogic filters, dead-letter -UI, HMAC signing) the context brief assumes, or would those need to be built fresh? - -## Approach Taken -- Read `lib/Settings/integriq_register.json`, `lib/Service/EventService.php`, - `lib/BackgroundJob/EventRetryJob.php`, `lib/Controller/EventsController.php`, - `lib/Service/WebhookSignatureService.php`, `lib/EventListener/*.php`, `lib/AppInfo/Application.php`, - `appinfo/routes.php`, `appinfo/info.xml` in this checkout at HEAD. -- Read the four target specs in full: `openspec/specs/events-cloudevents/spec.md`, - `openspec/specs/dead-letter-replay/spec.md`, `openspec/specs/webhook-signing/spec.md`, - `openspec/specs/consumer-management/spec.md`. -- Searched `/home/rubenlinde/nextcloud-docker-dev/workspace/server` (a live NC 33.0.0-dev master checkout) - for `OCP\Files\Events\Node\*`, `OCA\DAV\Events\*`, `apps/tables`, `apps/forms`. -- Searched this repo and sibling app repos for existing JsonLogic usage and existing OR-object-lifecycle - listener registration idiom. -- Verified integriq's existing ADR-023 (action authorization matrix) implementation by reading - `lib/Service/ActionAuthService.php`, `lib/Controller/ActionMatrixController.php`, - `lib/Repair/InitializeActions.php`, `lib/actions.seed.json`, the `EventsController` call sites, and the - `/api/admin/action-matrix` route registrations in `appinfo/routes.php`. - -## Findings -- **Files events — stable, verified.** `OCP\Files\Events\Node\{NodeCreatedEvent, NodeWrittenEvent, - NodeDeletedEvent, NodeRenamedEvent, NodeCopiedEvent, NodeTouchedEvent}` are genuine `OCP\*` public API, - `NodeCreatedEvent` present since NC 20.0.0. Safe to build against for the full NC 28–34 range. File - *tagging* has no dedicated `OCP` event in this class family — tag changes surface via - `OCP\SystemTag\MapperEvent` (a different, older stable OCP interface) rather than a `Node*Event`; the - file-tagged listener targets that event class instead of a `Node*Event` variant. -- **Calendar events — exist, but OCA not OCP.** `apps/dav/lib/Events/{CalendarCreatedEvent, - CalendarUpdatedEvent, CalendarDeletedEvent, CachedCalendarObjectCreatedEvent, - CachedCalendarObjectUpdatedEvent, CachedCalendarObjectDeletedEvent, ...}` are real, present in the NC 33 - checkout, and `dav` ships bundled with every NC instance (no feature-detection needed for "is calendar - present" — it always is), but the namespace is `OCA\DAV\Events`, i.e. app-internal API with no NC public-API - stability guarantee across major versions. Treat class names/constructor signatures as subject to change - between NC majors; isolate each listener so a signature break in one calendar event class cannot break the - file listeners. -- **Tables / Forms events — plausible but unverified in this environment.** Neither `apps/tables` nor - `apps/forms` exists in the checked server checkout, so `OCA\Tables\Event\*`/`OCA\Forms\Event\*` could not - be directly confirmed. Both are known, real, separately-installed Nextcloud apps that do expose their own - event classes in current versions per general Nextcloud ecosystem knowledge, but exact class names/payload - shapes are NOT verified here and MUST be feature-detected AND spot-checked against a real instance with - both apps installed before the listener implementation is trusted. `class_exists()` referencing a bare - `::class` string is safe even when the target app is absent (compile-time string, no autoload trigger) — - confirmed by reasoning about PHP's `::class` resolution, not by executing code in this environment. -- **The "reuse" machinery is real, not aspirational.** `WebhookSignatureService::sign()`/`verify()` IS - already called from inside `EventService::deliverMessage()` (constructor-injected, header emission at - lines ~311–328) — the context brief's phrasing ("wire existing WebhookSignatureService into subscription - delivery") reads as if this were still to be done; it is already done. Similarly `EventRetryJob` is - registered and running, and the dead-letter UI (`EventDeliveriesPage.vue`, - `EventDeliveryDetailModal.vue`) exists and is spec'd/implemented. This substantially de-risks the change: - no rebuild of delivery/retry/signing/dead-letter is needed, only a new producer path and a new - action-dispatch branch. -- **JsonLogic is present but NOT currently used for event filters.** `jwadhams/json-logic-php` is a real - composer dependency, used today only by `EndpointService`'s rule-condition engine - (`JsonLogic::apply($conditions, $data)`). `EventService::evaluateFilters()` has its own, separate - `exact`/`prefix`/`suffix`/`expression` dialect switch. Adding `jsonlogic` as a new case in that switch is - a small, additive change reusing the already-present library — not a new dependency, but also not - something that "just works today" as the brief's phrasing might suggest. -- **ADR-023 is FULLY implemented in integriq at HEAD** (correcting this discovery's own first research - pass, which wrongly reported it absent): `lib/Service/ActionAuthService.php` (`requireAction()`, `can()`, - `getMatrix()`/`setMatrix()`; admin always passes; matrix stored in `IAppConfig` key `actions`; any action - missing from the matrix defaults to `["admin"]` — default-deny by construction), - `lib/Controller/ActionMatrixController.php` (`GET`/`PUT /api/admin/action-matrix`, - `#[AuthorizedAdminSetting]`, and its `getMatrix()` response unions `lib/actions.seed.json` keys so newly - seeded actions surface in the admin UI automatically), `lib/Repair/InitializeActions.php` (seeds the - matrix from `lib/actions.seed.json` on install/upgrade without clobbering admin edits), and - `src/views/admin/ActionAuthMatrix.vue` (existing admin editor). Multiple controllers already enforce it — - including `EventsController` itself: `subscribe()`, `updateSubscription()`, `unsubscribe()`, - `subscriptions()`, `subscriptionMessages()`, `messages()`, and `pull()` each call `requireAction` with - seeded actions (`event.subscribe`, `event.update-subscription`, … — `lib/actions.seed.json` lines 27–33). - Self-service for NC-native event types is therefore a pure REUSE play: seed four new per-family actions - following the existing `.` naming convention and add per-family `requireAction` call sites — - no new authorization service, endpoint, or UI. This also means the `events-cloudevents` spec's REQ-005 - Note ("no auth on subscribe") is stale at the ACTION level at HEAD; the still-open residue is per-OBJECT - ownership (action RBAC gates who may call the endpoint, not which subscription they may touch). - -## Recommendation -**Proceed to specs and design as scoped** — extend the existing `events-cloudevents`/`dead-letter-replay`/ -`webhook-signing`/`consumer-management` machinery rather than building anything new for delivery, retry, -signing, or dead-lettering. For the producer side: build file listeners against `OCP\Files\Events\Node\*` -+ `OCP\SystemTag\MapperEvent` with full confidence (verified stable API); build the calendar listener -against `OCA\DAV\Events\*` with an explicit "OCA stability, not OCP" caveat documented at the listener -class level; build Tables/Forms listeners behind `IAppManager::isEnabledForAnyUser()` feature-detection AND -mark their exact event class names/payloads as TENTATIVE pending a live-instance check with both apps -installed (tracked as a task-list item, not blocking spec-writing — the subscription/delivery/filter -machinery around them is identical regardless of the exact upstream class name). - -## Risks Uncovered -- Tables/Forms event class names used in `design.md`/`tasks.md` (`OCA\Tables\Event\Row*Event`, - `OCA\Forms\Event\FormSubmitted`) are best-effort/plausible names, NOT confirmed against source. The - implementation task for these two listeners MUST start with a `find`/`grep` against a live checkout (or - the apps' published source) to confirm exact class names and constructor/payload shape before writing - listener code — flagged explicitly in `tasks.md`. -- `OCA\DAV\Events\*` class names/shapes could change across NC majors (28→34 spans several years of DAV - app changes) in ways this discovery did not diff version-by-version (only NC 33 master was checked). The - calendar listener needs a defensive `instanceof`/property-existence check rather than assuming a fixed - shape, and a compatibility note per supported NC version in the task's acceptance criteria. - -## Next Steps -Proceed to `specs/` (delta specs against `events-cloudevents`, plus one new `nextcloud-event-triggers` -capability spec for the producer side — see `design.md` for why the producer is a new capability while the -delivery-side changes are deltas to existing specs), then `tasks.md` with the Tables/Forms class-name -verification as an explicit early task. diff --git a/openspec/changes/nextcloud-event-hub/migration.md b/openspec/changes/nextcloud-event-hub/migration.md deleted file mode 100644 index a7f93f489..000000000 --- a/openspec/changes/nextcloud-event-hub/migration.md +++ /dev/null @@ -1,63 +0,0 @@ -# Migration: nextcloud-event-hub - -## Current State -`lib/Settings/integriq_register.json`'s `event_subscription` schema has no `action` or `retryPolicy` -properties; every subscription delivers via `sink` (implicit webhook) with the hardcoded backoff constants -in `EventService` (`RETRY_BASE_SECONDS=60`, `RETRY_FACTOR=4`, `RETRY_CAP_SECONDS=21600`, `maxRetries` default -parameter `5`). No Nextcloud SQL table or column exists for these fields — OR-managed schemas are stored as -JSON in OpenRegister's generic object table, not app-owned SQL tables, so there is nothing for a -`lib/Migration/VersionXXXXXXXXXX.php` class to alter. - -## Target State -`event_subscription` gains two new, fully optional properties in the register descriptor: -`action` (`{kind, sink?, synchronizationId?, jobId?}`) and `retryPolicy` -(`{baseSeconds?, factor?, capSeconds?, maxRetries?}`). Every existing subscription row is valid against the -new schema unchanged (both properties absent ⇒ existing default behaviour, per `design.md` Decision 3 and -Decision 4). - -## Migration Class -No `lib/Migration/VersionXXXXXXXXXX.php` is introduced. This is a register-descriptor content change only, -not a Nextcloud database schema migration. - -``` -No PHP migration class — schema-less OR storage. -File changed: lib/Settings/integriq_register.json (event_subscription.properties.action, -event_subscription.properties.retryPolicy added; register descriptor `version` bumped per existing -convention — see `openconnector-register-schema` spec REQ-A-001 for the versioning rule this change follows). -``` - -## Migration Steps -1. Add `action` and `retryPolicy` property definitions to the `event_subscription` schema in - `lib/Settings/integriq_register.json` (additive, no `required` entries added — both stay optional). -2. Bump the descriptor's schema `version` field per the existing per-schema semver convention (already used - elsewhere in this file, e.g. `event_subscription.version: "1.0.0"` → `"1.1.0"` for an additive, - backward-compatible change). -3. No data backfill: existing `event_subscription` rows are read as-is; `action`/`retryPolicy` resolve to - their code-level defaults (Decision 3/4) when absent, so no UPDATE statement runs against existing data. -4. On next app boot, `Application.php::register()` registers the 4 new `IEventDispatcher::addServiceListener` - calls (Decision 1) — this is a code-path activation, not a data migration, and takes effect immediately - on deploy with no separate step. - -## Data Impact -Zero existing records are modified. Zero data loss. The change is purely additive to the schema and runs -safely on live data — no downtime, no lock, no batch job. New `event` records begin appearing only after -deploy, sourced from newly-fired NC events; there is no retroactive backfill of historical NC activity (not -possible — NC core does not retain a queryable history of past file/calendar/table/form events for this app -to replay). - -## Rollback Procedure -Revert `lib/Settings/integriq_register.json` to drop the two new properties (or simply stop reading -them — since they are optional and additively handled, leaving stale `action`/`retryPolicy` values on -already-created subscriptions after a code rollback is harmless: the reverted `EventService` code simply -ignores fields it doesn't know about). Remove the 4 `addServiceListener` calls in `Application.php` to stop -producing NC-native `event` records. No reverse migration script is needed given step 3 above (no data was -transformed). - -## Validation -- `openspec validate nextcloud-event-hub --type change --strict` passes (schema-level check that the - descriptor delta is well-formed). -- After deploy: create one subscription with `action.kind: 'synchronization'` and confirm it validates and - round-trips (GET returns the same `action` block) via the existing `subscribe()`/`subscriptions()` - endpoints. -- Confirm a pre-existing subscription (no `action`/`retryPolicy` set) still delivers via webhook with the - unchanged 60s/×4/6h/5-retry schedule (regression check called out in `tasks.md`). diff --git a/openspec/changes/nextcloud-event-hub/proposal.md b/openspec/changes/nextcloud-event-hub/proposal.md index a4ae2f39f..c7ffbd33b 100644 --- a/openspec/changes/nextcloud-event-hub/proposal.md +++ b/openspec/changes/nextcloud-event-hub/proposal.md @@ -1,153 +1,52 @@ -# Proposal: nextcloud-event-hub - -## Summary -Integriq already ships a complete CloudEvents pipeline — `event`/`event_subscription`/`event_message` -schemas, filter-and-deliver logic (`EventService::processEvent`/`deliverMessage`), a scheduled retry sweep -(`EventRetryJob`), HMAC request signing (`WebhookSignatureService`, already wired into delivery), and a -dead-letter + replay UI (`EventDeliveriesPage.vue`) — but today it only reacts to **OpenRegister object** -lifecycle events (`com.nextcloud.openregister.object.*`). Nextcloud core's own event surface (files, -calendar, Tables, Forms) is exposed only through the admin-only, no-UI `webhook_listeners` app (NC 30+), -whose delivery is background-job-polled (up to 5 minutes of latency) and ships with no documented retries, -no signing, and no dead-lettering. This change adds in-process PHP listeners for Nextcloud core events, -normalizes them into the existing CloudEvents `event` envelope, and lets admins — and, for allow-listed -event types, non-admin users — subscribe those events to a synchronization, a job, or an outbound signed -webhook, entirely through machinery Integriq already operates and already tests. It is a pure -differentiator: no other App Store app offers guaranteed, self-service, in-process Nextcloud event routing. - -## Motivation -Specter deep-research insight #1250 identified this as a market gap: Nextcloud's own answer to -"do something when a file changes" is either `workflow_engine` (no outbound HTTP, no retries) or -`webhook_listeners` + a second product (Windmill) for anything resembling delivery guarantees. Every -Integriq building block needed to close this gap already exists and is already spec'd -(`events-cloudevents`, `dead-letter-replay`, `webhook-signing`, `consumer-management`) — the only missing -piece is the NC-core-event → `event` entity producer and a subscription trigger that isn't "another -CloudEvent". Building this now reuses machinery that is implemented and tested rather than forking it, -and turns four independently-shipped specs into one coherent, sellable capability. - -## Affected Projects -- [x] Project: `integriq` — new NC-core event listeners, `EventService` extensions, subscription - schema fields, self-service authorization gate, delivery-status UI additions. - -## Scope - -### In Scope -1. In-process PHP `IEventListener` classes for: NC file create/update/delete/tag (`OCP\Files\Events\Node\*`, - stable OCP since NC 20), calendar object create/update/delete (`OCA\DAV\Events\CachedCalendarObject*`, - bundled app but OCA-namespaced, not OCP-guaranteed), Tables row create/update/delete - (`OCA\Tables\Event\*`, optional app), Forms submission created (`OCA\Forms\Event\*`, optional app). - Each normalizes its NC event into the existing CloudEvents `event` envelope shape (REQ-004 pattern) - under a new `com.nextcloud...` type namespace and calls `EventService::processEvent`. -2. Per-event-type availability gating: Tables/Forms listeners are registered only when the source app is - enabled (`IAppManager::isEnabledForAnyUser`); calendar listeners are registered unconditionally (`dav` - ships with every NC instance) but documented as OCA-stability, not OCP-stability. -3. A `jsonlogic` filter dialect added to `EventService::evaluateFilters` (event-subscription payload - filtering), reusing the `jwadhams/json-logic-php` library already a composer dependency (currently used - only by the rule engine's `EndpointService`). -4. A subscription-level `action` field (`event_subscription.action = {kind, sink|synchronizationId|jobId}`) - so a matched NC event can drive a synchronization (`SynchronizationService::synchronize`), a job - (`JobService::executeJob`), or an outbound signed webhook (existing `deliverMessage` path) — not only - the last of the three, which is all `event_subscription` currently supports. -5. Per-subscription configurable retry policy (`event_subscription.retryPolicy = {baseSeconds, factor, - capSeconds, maxRetries}`), read by `deliverMessage`/`recordFailure` with the existing class constants - (60s / ×4 / 6h cap / 5 retries) as the default when absent — today these are hardcoded, non-configurable - constants. -6. Non-admin self-service subscription creation for NC event types an admin has explicitly allow-listed, - by reusing Integriq's existing ADR-023 implementation at HEAD (`ActionAuthService::requireAction`, - the `IAppConfig`-backed action matrix, `lib/actions.seed.json` seeding via `InitializeActions`, and the - existing `ActionAuthMatrix.vue` admin editor): four new per-event-family actions - (`event.subscribe-nextcloud-{files,calendar,tables,forms}`) seeded `["admin"]` (default-deny), layered - on the coarse `event.subscribe`/`event.update-subscription` actions `EventsController` already enforces. - No new authorization mechanism is introduced. -7. Delivery-status additions to the existing dead-letter/Event deliveries UI: NC-native event types are - filterable/visible alongside CloudEvents-sourced messages using the same components. -8. Tests: unit (filter dialects incl. `jsonlogic`, backoff schedule with custom `retryPolicy`), integration - (fire a real NC event → assert `event` + `event_message` + signature header; force a failing sink → - assert dead-letter entry and successful replay), Playwright (self-service subscription creation as a - non-admin, admin grant of the per-family actions via the existing action-matrix editor). - -### Out of Scope -- Kafka/MQTT sinks (existing `protocol` field already allows for it; no new work here — deferred). -- WorkflowEngine/Flow actions as a delivery target (possible follow-up change). -- Tables as a synchronization source/target (`tables-bridge` change owns that). -- Any change to the existing ADR-023 machinery itself (`ActionAuthService`, `ActionMatrixController`, - `ActionAuthMatrix.vue`, `InitializeActions`) — this change only ADDS seed entries to - `lib/actions.seed.json` and `requireAction` call sites; the framework is consumed as-is. -- Per-object ownership checks on subscription update/delete (the residual gap noted in the - `events-cloudevents` spec's REQ-005 Notes: action RBAC gates WHO may call the endpoint, not WHICH - subscription they may touch) — a candidate for a dedicated security-labelled follow-up change. - -## Approach -Follow the existing `lib/EventListener/` + `Application.php::register()` idiom used for -`ObjectCreatedEvent`/`ObjectUpdatedEvent`/`ObjectDeletedEvent` (`IEventDispatcher::addServiceListener`) -for the new NC-core listeners, so no new bootstrap pattern is introduced. Each listener maps its NC event -into the same `event` OR-object shape `EventService::handleObjectCreated` already writes, then calls the -same `processEvent`/`deliverMessage`/retry/dead-letter/signing pipeline unchanged. `EventService` gains a -small number of new methods (`handleNextcloudEvent`, an `action`-dispatch branch inside `processEvent`, -`jsonlogic` in `evaluateFilters`, reading `retryPolicy` off the subscription) rather than new services — -this is additive to one class plus new listener classes, not a parallel pipeline. - -## New Dependencies -None. `jwadhams/json-logic-php` is already a composer dependency (used by `EndpointService`); Tables/Forms -event classes are referenced only via `::class` (compile-time string, no autoload requirement), so no -Tables/Forms/dav packages are added as composer/npm dependencies. - -## Impact -- `lib/AppInfo/Application.php` — new `addServiceListener` registrations, feature-detected for Tables/Forms. -- `lib/EventListener/` — 4 new listener classes. -- `lib/Service/EventService.php` — new methods + `action`/`retryPolicy`/`jsonlogic` handling in existing methods. -- `lib/Settings/integriq_register.json` — `event_subscription` gains `action`, `retryPolicy`, - `allowedForGroups`-style authorization fields; `event` type vocabulary extended (documentation only, no - schema field change — `type` is already a free string). -- `lib/Controller/EventsController.php` — `subscribe()`/`updateSubscription()` gain per-family - `ActionAuthService::requireAction` calls for NC-native event types (layered on the coarse - `event.subscribe`/`event.update-subscription` actions those methods already enforce). -- `lib/actions.seed.json` — four new `event.subscribe-nextcloud-*` action entries (seeded `["admin"]`); - they surface automatically in the existing action-matrix admin UI (`ActionMatrixController::getMatrix` - unions seed keys), so no new settings endpoint or view is needed. -- `src/views/EventDelivery/`, `src/modals/EventDelivery/` — filter/display additions for NC-native events. - -## Cross-Project Dependencies -None outside integriq. Tables and Forms are Nextcloud App Store apps, not Conduction apps — treated -as optional runtime dependencies, feature-detected, never a hard `composer`/`info.xml` `` entry. - -## Risks - -### Risk 1: OCA (non-OCP) event classes are not covered by Nextcloud's API stability guarantee -**Severity:** Medium — **Mitigation:** Calendar (`OCA\DAV\Events\*`), Tables (`OCA\Tables\Event\*`), and -Forms (`OCA\Forms\Event\*`) events are app-specific, not `OCP\*` stable API. Each listener is isolated -per-event-type, wrapped so a signature change in one app's events cannot break the others or the file -listeners, and covered by a NC-version compatibility note in `design.md`. A failing listener logs and -continues rather than throwing into the dispatcher. - -### Risk 2: Broadening self-service via the action matrix could over-authorize if granularity is misjudged -**Severity:** Medium — **Mitigation:** Default-deny by construction — the four new actions are seeded -`["admin"]` and `ActionAuthService::getAllowedGroups` falls back to `["admin"]` for any action absent from -the matrix (covers upgraded installs whose matrix predates the new seed entries). The gate is layered: a -group needs BOTH the coarse `event.subscribe` grant AND the per-family grant. Scope stays narrow — existing -CloudEvents `subscribe()` behaviour for OR object events is unchanged, and the residual per-object ownership -gap (action RBAC does not check which subscription a caller may touch), flagged in the `events-cloudevents` -spec Notes, is out of scope for this change and not silently "fixed" by this gate. - -### Risk 3: JsonLogic filter expressions on subscriber-supplied payloads (same class of risk already flagged for the `expression` dialect) -**Severity:** Low — **Mitigation:** `jwadhams/json-logic-php` evaluates a restricted operation set (no -arbitrary code execution, unlike Symfony ExpressionLanguage's existing `expression` dialect) against the -event payload only; no filesystem/network primitives are exposed by the library. - -## Rollback Strategy -Each NC-core listener registration is an independent `addServiceListener` call — remove or comment out the -line(s) in `Application.php` to disable a single event family without affecting the others or the existing -OR-object pipeline. The `action`/`retryPolicy` subscription fields are additive and optional (absent ⇒ -existing push-webhook-only behaviour with existing default backoff constants), so no data migration is -required to roll back; subscriptions created with an `action` block simply stop being able to select -synchronization/job actions in the UI if the change is reverted; the underlying `event_subscription` -objects are untouched. - -## Open Questions -- Is per-event-FAMILY action granularity (4 actions: files/calendar/tables/forms) sufficient, or will - operators want per-event-TYPE granularity (11 actions)? `design.md` Decision 5 chooses per-family for - matrix legibility; finer granularity remains a pure seed-file addition later (no mechanism change), so - this is cheap to revisit. -- Exact Forms/Tables event class names and payload shapes could not be verified against a live install in - this repo (neither app is present in the checked server checkout) — `design.md` and `tasks.md` gate this - work behind a runtime `class_exists()` check and mark the specific event class names TENTATIVE pending - verification against an instance with both apps installed. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: nextcloud-event-hub (superseded — re-scoped 2026-09-02) + +This directory double-counted a change that had already shipped. The event +hub was implemented and archived on 2026-07-15 +(`archive/2026-07-15-nextcloud-event-hub`, 30/43 tasks checked with per-task +evidence), yet this live copy was left standing at 0/43. The machinery exists +at HEAD: `NextcloudFileEventListener`, `NextcloudFileTagEventListener`, +`NextcloudCalendarEventListener`, `NextcloudTablesEventListener` and +`NextcloudFormsEventListener` in `lib/EventListener/`, the `jsonlogic` filter +dialect in `EventService::evaluateFilters`, the subscription-level `action` +(synchronization | job | webhook) and `retryPolicy` fields, and the +per-family self-service actions in the ADR-023 matrix. + +Since then the outbound side has also grown past this change's framing: the +ADR-041 delivery seam (#1810) put `DeliveryRequestedEvent` / +`DeliveryConcludedEvent` into the same CloudEvents pipeline, and sibling-app +deliveries (dossiq publications) now arrive there. + +57 `@spec` tags in `lib/`, `src/` and `tests/` point at this directory's +three delta specs (`specs/nextcloud-event-triggers/spec.md`, +`specs/events-cloudevents/spec.md`, `specs/dead-letter-replay/spec.md`), so +the whole `specs/` tree stays exactly where it is as anchor targets. The +other artifacts (context brief, design, discovery, migration, test plan) are +removed; they survive verbatim in the archived twin and in git history. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| NC file/calendar/Tables/Forms listeners, CloudEvents normalization, availability gating, `jsonlogic` filter dialect, subscription `action` and `retryPolicy`, self-service family actions (tasks 1, 3-11, 13-14) | **Already shipped and archived**: `archive/2026-07-15-nextcloud-event-hub`, code at HEAD | +| Task 2 spike (Tables/Forms event class names never confirmed against a live instance), Playwright for the family-grant + self-service-subscribe flow (task 12) and the action-type picker (task 13's open Test box), Newman for subscribe-with-action/retryPolicy and the 403 paths, feature docs, screenshot | `openspec/changes/nextcloud-event-hub-verification` — **first shippable slice**, fully authored. The spike comes first: two of the five listener families ship against unverified class names, which is a silent no-op if they are wrong | +| Outbound sibling-app deliveries | Landed as the **ADR-041 delivery seam** (#1810); the dossiq scope continues in `openspec/changes/absorb-dossiq-deliveries` (7/11) | +| Event-driven orchestration beyond sync/job/webhook | `openspec/changes/nc-events-start-or-flows`: a matched subscription starts an OpenRegister flow run, so NC events reach the one engine instead of growing a fourth app-local action kind. No app-local scheduler or workflow machinery | + +## Sequencing + +`nextcloud-event-hub-verification` is independent and ready to hand to an +agent today. `nc-events-start-or-flows` SHOULD wait for +`integriq-flow-nodes` to land so a triggered flow has call/sync nodes worth +running. Nothing remains to implement from this change directly. + +## Archival + +This directory is retired in place (not moved or renamed): its `specs/` tree +is a live `@spec` anchor target for 57 tags, and a rename would both break +those tags and detonate every diff-scoped gate. Archive it via the normal +flow only after those tags are repointed at the main specs. diff --git a/openspec/changes/nextcloud-event-hub/tasks.md b/openspec/changes/nextcloud-event-hub/tasks.md index 7ff962a67..49e137c2d 100644 --- a/openspec/changes/nextcloud-event-hub/tasks.md +++ b/openspec/changes/nextcloud-event-hub/tasks.md @@ -1,233 +1,14 @@ -# Tasks: nextcloud-event-hub +# Tasks: nextcloud-event-hub (superseded) -## Implementation Tasks +The original 14-task / 43-checkbox list is removed with the 2026-09-02 +re-scope (see proposal.md for the full disposition; the list survives in +`archive/2026-07-15-nextcloud-event-hub/tasks.md`, where 30/43 boxes are +checked with per-task evidence, and in git history). Work items live in the +successor changes: -### Task 1: Add `action` and `retryPolicy` properties to the `event_subscription` register descriptor -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **files**: `lib/Settings/integriq_register.json` -- **acceptance_criteria**: - - GIVEN the descriptor is parsed WHEN inspecting `components.schemas.event_subscription.properties` THEN - `action` and `retryPolicy` object properties exist, both absent from `required` - - GIVEN an existing seeded subscription with neither field set WHEN re-validated against the updated - schema THEN it remains valid (no regression) -- [ ] Implement -- [ ] Test +- `nextcloud-event-hub-verification` (ready to start) +- `nc-events-start-or-flows` (after integriq-flow-nodes lands) +- `absorb-dossiq-deliveries` (in flight, 7/11) -### Task 2: Spike — confirm Tables and Forms event class names against a live instance -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-tables-row-events-must-be-normalized-to-cloudevents-when-the-tables-app-is-installed-req-003` -- **files**: none (research spike; update `discovery.md` findings and this task's own notes if names differ - from the TENTATIVE ones used elsewhere in this change) -- **acceptance_criteria**: - - GIVEN a Nextcloud instance with `tables` and `forms` installed WHEN inspecting their `Event`/`Events` - namespaces THEN the exact class names, constructor signatures, and available payload accessors for - row create/update/delete and form-submission-created are documented -- [ ] Implement -- [ ] Test - -### Task 3: Extend `EventService::evaluateFilters` with the `jsonlogic` dialect -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-cloudevent-fan-out-to-matching-subscriptions-req-001` -- **files**: `lib/Service/EventService.php` -- **acceptance_criteria**: - - GIVEN a filter `{jsonlogic: {"in": ["invoice", {"var": "data.attributes.tags"}]}}` and a matching event - WHEN `evaluateFilters` runs THEN it returns `true` via `JsonLogic::apply` - - GIVEN the existing `exact`/`prefix`/`suffix`/`expression` dialects WHEN their existing tests run THEN - they are unaffected (regression) -- [ ] Implement -- [ ] Test - -### Task 4: Add subscription-level `retryPolicy` override to `deliverMessage`/`recordFailure`/`processRetries` -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-push-delivery-with-status-tracking-and-retry-sweep-req-002` -- **files**: `lib/Service/EventService.php` -- **acceptance_criteria**: - - GIVEN a subscription with `retryPolicy = {baseSeconds: 30, factor: 2, capSeconds: 1800, maxRetries: 3}` - WHEN a delivery fails THEN `nextAttempt` uses the custom schedule and the message abandons after 3 - failures, not 5 - - GIVEN a subscription with a partial `retryPolicy` (only `maxRetries`) WHEN a delivery fails THEN the - unset keys fall back to the existing class constants - - GIVEN a subscription with no `retryPolicy` WHEN deliveries fail repeatedly THEN behaviour is byte-for-byte - identical to pre-change (60s / ×4 / 6h cap / 5 retries) — regression test required -- [ ] Implement -- [ ] Test - -### Task 5: Add `event_subscription.action` dispatch to `EventService::processEvent` -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **files**: `lib/Service/EventService.php` -- **acceptance_criteria**: - - GIVEN `action` absent WHEN a message is dispatched THEN `deliverMessage` runs exactly as before - (regression) - - GIVEN `action = {kind: 'synchronization', synchronizationId}` WHEN dispatched THEN - `SynchronizationService::synchronize` runs and NOT `deliverMessage`; failure enters the standard - retry/backoff/abandon machine - - GIVEN `action = {kind: 'job', jobId}` WHEN dispatched THEN `JobService::executeJob(forceRun: true)` runs - - GIVEN an unrecognised `action.kind` WHEN dispatched THEN the message fails once with `retryCount` - unchanged (no retry loop) -- [ ] Implement -- [ ] Test - -### Task 6: File event listener (`OCP\Files\Events\Node\*`, `OCP\SystemTag\MapperEvent`) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-file-events-must-be-normalized-to-cloudevents-req-001` -- **files**: `lib/EventListener/NextcloudFileEventListener.php`, `lib/EventListener/NextcloudFileTagEventListener.php`, - `lib/Service/EventService.php` (new `handleNextcloudEvent` method), `lib/AppInfo/Application.php` -- **acceptance_criteria**: - - GIVEN a file is created/updated/deleted WHEN NC dispatches the corresponding `Node*Event` THEN a - correctly-typed, correctly-sourced `event` record is persisted and `processEvent` is invoked - - GIVEN a file's system tag changes WHEN `MapperEvent` fires THEN `com.nextcloud.files.node.tagged` is - persisted, distinct from create/update/delete -- [ ] Implement -- [ ] Test - -### Task 7: Calendar event listener (`OCA\DAV\Events\CachedCalendarObject*Event`) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-calendar-events-must-be-normalized-to-cloudevents-with-an-oca-stability-caveat-req-002` -- **files**: `lib/EventListener/NextcloudCalendarEventListener.php`, `lib/AppInfo/Application.php` -- **acceptance_criteria**: - - GIVEN a calendar object is created/updated/deleted WHEN NC dispatches the corresponding `Cached*Event` - THEN a correctly-typed `event` record is persisted - - GIVEN a fired event lacks an expected accessor (defensive `method_exists` check) WHEN `handle()` runs - THEN it logs a warning and returns without throwing into the NC event dispatcher -- [ ] Implement -- [ ] Test - -### Task 8: Tables row event listener (feature-detected) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-tables-row-events-must-be-normalized-to-cloudevents-when-the-tables-app-is-installed-req-003` -- **files**: `lib/EventListener/NextcloudTablesEventListener.php`, `lib/AppInfo/Application.php` -- **acceptance_criteria**: - - GIVEN `tables` is not installed WHEN Integriq boots THEN no listener is registered and no error is - logged - - GIVEN `tables` is installed and a row is created/updated/deleted WHEN the row event fires THEN a - correctly-typed `event` record is persisted -- [ ] Implement -- [ ] Test - -### Task 9: Forms submission event listener (feature-detected) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-forms-submission-events-must-be-normalized-to-cloudevents-when-the-forms-app-is-installed-req-004` -- **files**: `lib/EventListener/NextcloudFormsEventListener.php`, `lib/AppInfo/Application.php` -- **acceptance_criteria**: - - GIVEN `forms` is not installed WHEN Integriq boots THEN no listener is registered - - GIVEN `forms` is installed and a form is submitted WHEN the submission event fires THEN a correctly-typed - `event` record is persisted with `data.formId` -- [ ] Implement -- [ ] Test - -### Task 10: Per-family `requireAction` gate in `EventsController` (ADR-023 reuse) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-non-admin-subscription-requests-for-nc-native-types-must-be-gated-via-the-existing-adr-023-action-matrix-req-005` -- **files**: `lib/Controller/EventsController.php` (no new service — reuses the already-injected - `ActionAuthService`; add the `types[]` → `event.subscribe-nextcloud-` mapping and the per-family - `requireAction` calls in `subscribe()` and `updateSubscription()`, after the existing coarse - `event.subscribe`/`event.update-subscription` checks) -- **acceptance_criteria**: - - GIVEN a non-admin whose groups hold `event.subscribe` but NOT the family action (seeded `["admin"]`) - WHEN `subscribe()` is called with an NC-native type THEN 403 (`OCSForbiddenException` from - `requireAction`) - - GIVEN a non-admin whose NC group is granted BOTH `event.subscribe` and the relevant - `event.subscribe-nextcloud-` action WHEN `subscribe()` is called THEN 200 - - GIVEN an admin caller WHEN `subscribe()` is called with any NC-native type THEN the gate never rejects - (`requireAction` admin bypass) - - GIVEN `types[]` contains only `com.nextcloud.openregister.*` (or other non-NC-native) entries WHEN - `subscribe()`/`updateSubscription()` run THEN no per-family action is checked (pre-existing - coarse-action-only behaviour unchanged) — regression test required -- [ ] Implement -- [ ] Test - -### Task 11: Seed the four `event.subscribe-nextcloud-*` actions in `lib/actions.seed.json` -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-the-four-per-family-actions-must-be-seeded-into-the-existing-action-matrix-req-006` -- **files**: `lib/actions.seed.json` (four new entries, each `["admin"]`; no changes to - `InitializeActions.php`, `ActionMatrixController.php`, or `ActionAuthMatrix.vue` — the existing repair - step seeds them and the existing matrix endpoint/UI surfaces them automatically) -- **acceptance_criteria**: - - GIVEN a fresh install WHEN the `InitializeActions` repair step runs THEN the matrix contains all four - `event.subscribe-nextcloud-*` actions mapped to `["admin"]` - - GIVEN an admin WHEN they open `GET /api/admin/action-matrix` (or the ActionAuthMatrix editor) THEN the - four new actions are listed (seed-key union) without any endpoint or UI change - - GIVEN an upgraded install whose stored matrix predates the new keys WHEN a non-admin attempts an - NC-native subscribe THEN `getAllowedGroups` falls back to `["admin"]` and the request is rejected - (fail-closed) — regression test required -- [ ] Implement -- [ ] Test - -### Task 12: Playwright regression — grant a family via the existing matrix editor, then self-service subscribe -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-the-four-per-family-actions-must-be-seeded-into-the-existing-action-matrix-req-006` -- **files**: `tests/e2e/spec-coverage/nextcloud-event-triggers.spec.ts` (no production UI files — the - admin surface is the pre-existing `src/views/admin/ActionAuthMatrix.vue`, unchanged) -- **acceptance_criteria**: - - GIVEN an admin in the existing Action authorization matrix editor WHEN they grant - `event.subscribe` and `event.subscribe-nextcloud-files` to a test group and save THEN the grants - persist and reloading the editor shows them - - GIVEN a non-admin member of that group WHEN they create a file-event subscription through the UI THEN - the subscription is created (and the same flow WITHOUT the family grant is rejected with a visible - error) -- [ ] Implement -- [ ] Test - -### Task 13: Self-service subscription action-type picker in the subscription modal -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **files**: `src/modals/EventSubscription/SubscriptionActionFields.vue` -- **acceptance_criteria**: - - GIVEN a user creating a subscription WHEN they choose "Synchronization" or "Job" as the action THEN the - corresponding target picker (synchronization/job selector) appears and its selection is submitted as - `action.synchronizationId`/`action.jobId` -- [ ] Implement -- [ ] Test - -### Task 14: Action-kind badge and Nextcloud-event provenance filter in the dead-letter UI -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-dead-letter-listing-and-detail-must-surface-action-kind-and-nextcloud-event-provenance-req-dlr-007` -- **files**: `src/views/EventDelivery/EventDeliveriesPage.vue`, `src/modals/EventDelivery/EventDeliveryDetailModal.vue`, - `lib/Controller/EventsController.php` (dead-letter list/detail response fields) -- **acceptance_criteria**: - - GIVEN dead-lettered messages of mixed `action.kind` WHEN the Event deliveries view renders THEN each - row shows its own action-kind badge - - GIVEN a mix of `/nextcloud/*`-sourced and `/objects/*`-sourced messages WHEN the "Nextcloud event" - filter is applied THEN only `/nextcloud/*`-sourced messages remain -- [ ] Implement -- [ ] Test - -### Task 15: Action-aware replay -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-audited-replay-returning-the-message-to-the-delivery-machine-req-dlr-003` -- **files**: `lib/Controller/EventsController.php`, `lib/Service/EventService.php` -- **acceptance_criteria**: - - GIVEN an abandoned message with `action.kind = 'synchronization'` WHEN an admin replays it THEN - `SynchronizationService::synchronize` runs (not an HTTP call) and success sets `status='delivered'` - - GIVEN an abandoned webhook-kind message WHEN replayed THEN behaviour is unchanged (regression) -- [ ] Implement -- [ ] Test - -### Task 16: Integration test — end-to-end NC event → delivery → signature → dead-letter → replay -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-file-events-must-be-normalized-to-cloudevents-req-001` -- **files**: `tests/Integration/NextcloudEventDeliveryTest.php` -- **acceptance_criteria**: - - GIVEN a real `NodeCreatedEvent` fired against a subscription with a signing secret WHEN delivery runs - THEN the outbound request carries a verifiable `X-OpenConnector-Signature` header - - GIVEN the sink fails 3 times consecutively WHEN the retry sweep runs THEN the message reaches - `abandoned` and appears in the dead-letter listing, and replaying it after the sink recovers delivers it -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/Service/EventServiceTest.php` extended; - `tests/Unit/Controller/EventsControllerTest.php` extended for the per-family `requireAction` gate; - new `tests/Unit/EventListener/Nextcloud*EventListenerTest.php`) -- [ ] Newman/Postman tests for new/changed API endpoints (subscribe with `action`/`retryPolicy`, per-family - 403/200 gate outcomes against the existing `/api/admin/action-matrix` grants, action-aware replay) -- [ ] Browser tests (Playwright MCP) for UI changes (family grant via the existing ActionAuthMatrix editor - followed by self-service subscribe, subscription action-type picker, dead-letter action-kind badge + - provenance filter) -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` (new "Nextcloud event triggers" page describing file/calendar/ - Tables/Forms subscriptions, self-service via the ADR-023 action matrix, and action types) -- [ ] Screenshot captured and committed to `docs/images/` (action matrix editor showing the new - `event.subscribe-nextcloud-*` rows, subscription action picker, dead-letter action-kind badges) - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for: self-service 403 error message, - subscription action-type picker, dead-letter action-kind badges and provenance filter label (the - action-matrix editor's existing strings are unchanged) +The `specs/` tree stays in this directory as the anchor target for 57 `@spec` +tags. There is nothing to implement from this change directly. diff --git a/openspec/changes/nextcloud-event-hub/test-plan.md b/openspec/changes/nextcloud-event-hub/test-plan.md deleted file mode 100644 index b8d0051a8..000000000 --- a/openspec/changes/nextcloud-event-hub/test-plan.md +++ /dev/null @@ -1,276 +0,0 @@ -# Test Plan: nextcloud-event-hub - -## Test Cases - -### TC-1: File create/update/delete produces a correctly-shaped event -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-file-events-must-be-normalized-to-cloudevents-req-001` -- **type**: api -- **preconditions**: an active `event_subscription` with `types = ["com.nextcloud.files.node.created"]` -- **steps**: upload a file via the WebDAV/Files API -- **expected result**: an `event` record is persisted with `type = 'com.nextcloud.files.node.created'`, - `source = '/nextcloud/files'`, `data.fileid`/`data.path` populated; `processEvent` produces a matching - `event_message` -- **test command**: `/test-api` - -### TC-2: File tag change is distinctly typed from create/update/delete -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-file-events-must-be-normalized-to-cloudevents-req-001` -- **type**: api -- **preconditions**: subscription with `types = ["com.nextcloud.files.node.tagged"]` -- **steps**: apply a system tag to a file -- **expected result**: `event.type = 'com.nextcloud.files.node.tagged'` -- **test command**: `/test-api` - -### TC-3: Calendar object creation is captured -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-calendar-events-must-be-normalized-to-cloudevents-with-an-oca-stability-caveat-req-002` -- **type**: api -- **preconditions**: subscription with `types = ["com.nextcloud.calendar.object.created"]` -- **steps**: create a calendar event via CalDAV -- **expected result**: `event.type = 'com.nextcloud.calendar.object.created'`, `data.calendarId`/`data.objectUri` populated -- **test command**: `/test-api` - -### TC-4: Unexpected DAV event shape is logged and skipped, not thrown -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-calendar-events-must-be-normalized-to-cloudevents-with-an-oca-stability-caveat-req-002` -- **type**: regression -- **preconditions**: a mocked `CachedCalendarObjectCreatedEvent` missing an expected accessor -- **steps**: dispatch the malformed event in a unit test -- **expected result**: `NextcloudCalendarEventListener::handle()` logs a warning, returns without persisting - or throwing; other listeners unaffected -- **test command**: `/test-regression` - -### TC-5: Tables listener is absent when Tables app is not installed -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-tables-row-events-must-be-normalized-to-cloudevents-when-the-tables-app-is-installed-req-003` -- **type**: functional -- **preconditions**: an NC instance without `tables` installed -- **steps**: boot Integriq -- **expected result**: no `NextcloudTablesEventListener` registration occurs, no error logged -- **test command**: `/test-functional` - -### TC-6: Tables row update produces an event when Tables is installed -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-tables-row-events-must-be-normalized-to-cloudevents-when-the-tables-app-is-installed-req-003` -- **type**: api -- **preconditions**: `tables` installed; subscription with `types = ["com.nextcloud.tables.row.updated"]` -- **steps**: edit a row via the Tables API -- **expected result**: `event.type = 'com.nextcloud.tables.row.updated'`, `data.tableId`/`data.rowId` populated -- **test command**: `/test-api` - -### TC-7: Forms submission produces an event when Forms is installed -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-forms-submission-events-must-be-normalized-to-cloudevents-when-the-forms-app-is-installed-req-004` -- **type**: api -- **preconditions**: `forms` installed; subscription with `types = ["com.nextcloud.forms.submission.created"]` -- **steps**: submit a form -- **expected result**: `event.type = 'com.nextcloud.forms.submission.created'`, `data.formId` populated -- **test command**: `/test-api` - -### TC-8: Non-admin lacking the per-family action grant is rejected for an NC-native type -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-non-admin-subscription-requests-for-nc-native-types-must-be-gated-via-the-existing-adr-023-action-matrix-req-005` -- **type**: security -- **persona**: Priya (ZZP Developer / Integrator) — a technically capable non-admin user attempting - self-service -- **preconditions**: action matrix has `event.subscribe-nextcloud-files = ["admin"]` (seeded default); - caller's group holds the coarse `event.subscribe` grant -- **steps**: non-admin calls `POST /api/events/subscriptions` with `types = ["com.nextcloud.files.node.created"]` -- **expected result**: HTTP 403 (`ActionAuthService::requireAction` throws for the family action) -- **test command**: `/test-security` - -### TC-9: Non-admin succeeds once their group is granted the per-family action -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-non-admin-subscription-requests-for-nc-native-types-must-be-gated-via-the-existing-adr-023-action-matrix-req-005` -- **type**: functional -- **persona**: Priya (ZZP Developer / Integrator) -- **preconditions**: matrix grants the caller's NC group both `event.subscribe` and - `event.subscribe-nextcloud-files` -- **steps**: subscribe via the UI with a `com.nextcloud.files.*` type -- **expected result**: subscription created, HTTP 200 -- **test command**: `/test-functional` - -### TC-10: Admin is never gated regardless of matrix state -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-non-admin-subscription-requests-for-nc-native-types-must-be-gated-via-the-existing-adr-023-action-matrix-req-005` -- **type**: regression -- **preconditions**: seeded admin-only matrix defaults for all four `event.subscribe-nextcloud-*` actions -- **steps**: admin subscribes to any NC-native type -- **expected result**: succeeds regardless of matrix state (`requireAction` admin bypass) -- **test command**: `/test-regression` - -### TC-11: Subscribing to a pre-existing (non-NC-native) type triggers no per-family check -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-non-admin-subscription-requests-for-nc-native-types-must-be-gated-via-the-existing-adr-023-action-matrix-req-005` -- **type**: regression -- **preconditions**: non-admin whose group holds the coarse `event.subscribe` grant; no - `event.subscribe-nextcloud-*` grants -- **steps**: subscribe with `types = ["com.nextcloud.openregister.object.created"]` only -- **expected result**: no per-family action is checked; pre-existing coarse-action-only `subscribe()` - behaviour applies unchanged -- **test command**: `/test-regression` - -### TC-12: Admin grants the Tables family to a group via the existing action-matrix editor -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-the-four-per-family-actions-must-be-seeded-into-the-existing-action-matrix-req-006` -- **type**: functional -- **persona**: Noor (Municipal CISO / Functional Admin) -- **preconditions**: `event.subscribe-nextcloud-tables = ["admin"]` (seeded default); the four new actions - are visible in the existing ActionAuthMatrix editor (seed-key union, no UI change) -- **steps**: admin opens the existing Action authorization matrix editor, adds `openconnector-power-users` - to `event.subscribe-nextcloud-tables`, saves (`PUT /api/admin/action-matrix`) -- **expected result**: mapping persists; reload shows it; group members holding `event.subscribe` can then - self-service-subscribe to `com.nextcloud.tables.*` types -- **test command**: `/test-functional` - -### TC-13: Non-admin cannot read or write the action matrix -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-the-four-per-family-actions-must-be-seeded-into-the-existing-action-matrix-req-006` -- **type**: security -- **preconditions**: authenticated non-admin -- **steps**: call `GET`/`PUT /api/admin/action-matrix` -- **expected result**: rejected by `AuthorizedAdminSetting` (pre-existing behaviour, regression-confirmed) -- **test command**: `/test-security` - -### TC-13b: Upgraded install whose matrix predates the seed entries stays fail-closed -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-the-four-per-family-actions-must-be-seeded-into-the-existing-action-matrix-req-006` -- **type**: security -- **preconditions**: stored matrix without any `event.subscribe-nextcloud-*` keys (simulating an upgrade - before the repair step runs) -- **steps**: non-admin (holding coarse `event.subscribe`) attempts an NC-native subscribe -- **expected result**: HTTP 403 — `getAllowedGroups` falls back to `["admin"]` for the missing action -- **test command**: `/test-security` - -### TC-14: jsonlogic filter evaluates correctly and short-circuits like other dialects -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-cloudevent-fan-out-to-matching-subscriptions-req-001` -- **type**: api -- **preconditions**: subscription filter `[{jsonlogic: {"in": ["invoice", {"var": "data.attributes.tags"}]}}]` -- **steps**: fire a matching and a non-matching event -- **expected result**: `evaluateFilters` returns `true`/`false` correctly via `JsonLogic::apply` -- **test command**: `/test-api` - -### TC-15: Default action (webhook) delivery is unchanged -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **type**: regression -- **preconditions**: subscription with no `action` field -- **steps**: fire a matching event -- **expected result**: `deliverMessage` invoked exactly as pre-change; signing/retry/dead-letter behaviour - identical -- **test command**: `/test-regression` - -### TC-16: action.kind=synchronization runs the synchronization, not an HTTP call -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **type**: api -- **preconditions**: subscription with `action = {kind: 'synchronization', synchronizationId}` -- **steps**: fire a matching event -- **expected result**: `SynchronizationService::synchronize` invoked; no HTTP request made; success → - `status='delivered'` -- **test command**: `/test-api` - -### TC-17: action.kind=synchronization failure enters the standard retry/backoff/abandon machine -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **type**: api -- **preconditions**: same as TC-16, synchronization configured to fail -- **steps**: fire the event repeatedly across sweep cycles -- **expected result**: retryCount increments, backoff schedule applies, eventually `abandoned` -- **test command**: `/test-api` - -### TC-18: action.kind=job runs the job, not an HTTP call -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **type**: api -- **preconditions**: subscription with `action = {kind: 'job', jobId}` -- **steps**: fire a matching event -- **expected result**: `JobService::executeJob(forceRun: true)` invoked -- **test command**: `/test-api` - -### TC-19: Unrecognised action.kind fails once without entering the retry loop -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008` -- **type**: api -- **preconditions**: subscription with `action = {kind: 'carrier-pigeon'}` -- **steps**: fire a matching event -- **expected result**: message `status='failed'` with descriptive error, `retryCount` stays 0 -- **test command**: `/test-api` - -### TC-20: Custom retryPolicy overrides the default backoff schedule -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-push-delivery-with-status-tracking-and-retry-sweep-req-002` -- **type**: api -- **preconditions**: subscription `retryPolicy = {baseSeconds: 30, factor: 2, capSeconds: 1800, maxRetries: 3}` -- **steps**: fail delivery repeatedly -- **expected result**: backoff follows the custom schedule; abandons after 3 failures -- **test command**: `/test-api` - -### TC-21: Partial retryPolicy only overrides the keys it sets -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-retrybackoff-policy-must-be-independently-configurable-req-009` -- **type**: api -- **preconditions**: subscription `retryPolicy = {maxRetries: 8}` only -- **steps**: fail delivery repeatedly -- **expected result**: baseSeconds/factor/capSeconds use defaults; abandons after 8 failures -- **test command**: `/test-api` - -### TC-22: Subscription without retryPolicy is byte-for-byte unchanged (regression) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/events-cloudevents/spec.md#requirement-a-subscriptions-retrybackoff-policy-must-be-independently-configurable-req-009` -- **type**: regression -- **preconditions**: subscription with no `retryPolicy` -- **steps**: fail delivery repeatedly -- **expected result**: 60s / ×4 / 6h cap / 5 retries — identical to pre-change behaviour -- **test command**: `/test-regression` - -### TC-23: Replaying an abandoned synchronization-action message re-runs the synchronization -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-audited-replay-returning-the-message-to-the-delivery-machine-req-dlr-003` -- **type**: api -- **preconditions**: abandoned message with `action.kind = 'synchronization'` -- **steps**: admin calls replay -- **expected result**: `SynchronizationService::synchronize` invoked, not an HTTP call; success → delivered -- **test command**: `/test-api` - -### TC-24: Replaying an abandoned webhook message is unchanged (regression) -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-audited-replay-returning-the-message-to-the-delivery-machine-req-dlr-003` -- **type**: regression -- **preconditions**: abandoned message, `action` absent, sink now healthy -- **steps**: admin calls replay -- **expected result**: `deliverMessage` invoked; message delivered; prior `attempts[]` preserved -- **test command**: `/test-regression` - -### TC-25: Dead-letter list shows action-kind badges per row -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-dead-letter-listing-and-detail-must-surface-action-kind-and-nextcloud-event-provenance-req-dlr-007` -- **type**: functional -- **persona**: Noor (Municipal CISO / Functional Admin) -- **preconditions**: dead-lettered messages with mixed action kinds -- **steps**: admin opens Event deliveries view -- **expected result**: each row displays its own action-kind badge -- **test command**: `/test-functional` - -### TC-26: Nextcloud-event provenance filter excludes OR-object events despite shared type prefix -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-dead-letter-listing-and-detail-must-surface-action-kind-and-nextcloud-event-provenance-req-dlr-007` -- **type**: functional -- **preconditions**: a mix of `/nextcloud/files`-sourced and `/objects/person`-sourced dead-lettered messages - (the latter carrying a `com.nextcloud.openregister.object.created` type, sharing the `com.nextcloud.` - prefix with the new producer namespace) -- **steps**: admin applies the "Nextcloud event" provenance filter -- **expected result**: only `/nextcloud/*`-sourced messages remain; OR-object messages are excluded despite - the shared type prefix -- **test command**: `/test-functional` - -### TC-27: HMAC signature present and verifiable on an NC-event-sourced webhook delivery -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/nextcloud-event-triggers/spec.md#requirement-file-events-must-be-normalized-to-cloudevents-req-001` -- **type**: api -- **preconditions**: subscription with a `signingSecret` and `types = ["com.nextcloud.files.node.created"]` -- **steps**: fire a file-created event; capture the outbound request -- **expected result**: `X-OpenConnector-Signature` header present; recomputed HMAC matches -- **test command**: `/test-api` - -### TC-28: Full failure→dead-letter→replay loop for an NC-native event -- **spec_ref**: `openspec/changes/nextcloud-event-hub/specs/dead-letter-replay/spec.md#requirement-audited-replay-returning-the-message-to-the-delivery-machine-req-dlr-003` -- **type**: api -- **preconditions**: subscription to an NC-native type with an unreachable sink -- **steps**: fire the event, let the retry sweep exhaust `maxRetries`, then fix the sink and replay -- **expected result**: message reaches `abandoned`, appears in dead-letter listing, and delivers successfully - after replay -- **test command**: `/test-api` - -## Coverage Summary -- `nextcloud-event-triggers` REQ-001–REQ-006: covered by TC-1, TC-2, TC-3, TC-4, TC-5, TC-6, TC-7, TC-8, - TC-9, TC-10, TC-11, TC-12, TC-13, TC-13b. -- `events-cloudevents` REQ-001 (MODIFIED, jsonlogic dialect): covered by TC-14. -- `events-cloudevents` REQ-002 (MODIFIED, retryPolicy override): covered by TC-20, TC-22. -- `events-cloudevents` REQ-008 (ADDED, action dispatch): covered by TC-15, TC-16, TC-17, TC-18, TC-19. -- `events-cloudevents` REQ-009 (ADDED, configurable retry policy): covered by TC-21 (TC-20/TC-22 also apply). -- `dead-letter-replay` REQ-DLR-003 (MODIFIED, action-aware replay): covered by TC-23, TC-24, TC-28. -- `dead-letter-replay` REQ-DLR-007 (ADDED, provenance/badges): covered by TC-25, TC-26. -- Signing (unchanged `webhook-signing` REQ-WHS-001, exercised against the new producer path): covered by - TC-27. - -## Out of Scope -- `consumer-management` and `webhook-signing` receive no spec deltas in this change (existing mechanisms are - reused unmodified) — no dedicated new test cases beyond TC-27's regression-style confirmation that signing - still applies correctly when the event's producer is new. -- Kafka/MQTT sink delivery, WorkflowEngine/Flow actions, and Tables-as-sync-source/target are out of this - change's scope per `proposal.md` and are not tested here. diff --git a/openspec/changes/visual-flow-orchestration/design.md b/openspec/changes/visual-flow-orchestration/design.md deleted file mode 100644 index 78527acc6..000000000 --- a/openspec/changes/visual-flow-orchestration/design.md +++ /dev/null @@ -1,351 +0,0 @@ -# Design: visual-flow-orchestration - -## Architecture Overview - -``` -Trigger (cron job | endpoint rule | CloudEvent | manual "Run") - │ - ▼ -FlowRunnerService::run(ObjectEntity $flow, array $input, ?string $triggerSource) - │ loads $flow->getObject()['steps'] (sorted by 'order') - │ creates a FlowToken (flow-token-helper) as the shared step context - │ creates a `flow_run` OR object (status: running) - │ - ├─ for each step (or the branch-selected next step): - │ ├─ evaluate step['condition'] via JWadhams\JsonLogic::apply() against - │ │ the current FlowToken-derived data — same call EndpointService - │ │ ::checkRuleConditions() already makes; skip step (log 'skipped') - │ │ if false - │ ├─ dispatch by step['type']: - │ │ call -> CallService::call() - │ │ mapping -> MappingService::executeMapping() - │ │ synchronization -> SynchronizationService::synchronize(..., flowToken: $token) - │ │ event -> EventService::emitCloudEvent() - │ │ approval -> ApprovalService::suspend()-equivalent, then - │ │ FlowRunnerService::run() RETURNS (run status: suspended) - │ │ branch -> evaluate branches[].condition in order, - │ │ pick nextStepOrder (or defaultNextStepOrder) - │ ├─ write a flow_run_log row (stepOrder, type, status, timing, error?) - │ └─ on error: apply onError (stop | continue | dead_letter) - │ - └─ mark flow_run 'completed' | 'stopped' | 'dead_letter' | 'suspended' - -Resume (approval granted): -ApprovalsController::approve() -> ApprovalService rehydrates -> for a -flow-sourced approval_request, dispatches to -FlowRunnerService::resumeFromApproval($flowRun, $approvalRequest) -instead of EndpointService::resumeFromApproval() — same pattern, new -sibling entrypoint, not a shared generic one (none exists today). -``` - -The runner is pure orchestration: every unit of actual work (calling an -HTTP source, running a mapping, executing a sync, emitting an event, -gating on approval) is delegated to the existing service that already -does that job. `FlowRunnerService` owns exactly three things a flow adds -that nothing else does: step ordering, condition/branch evaluation, and -per-step error-policy + trace recording. - -## Goals / Non-Goals - -**Goals:** sequential, declarative, referencing-not-reimplementing -multi-step execution; branch (single-target jump) control flow; approval -suspension mid-flow; per-step trace; cron/endpoint/event/manual triggers; -a typed step-list editor. - -**Non-Goals:** drag-and-drop canvas, parallel/fan-out, loops/iteration, -a new generic cross-service context object, a new generic -"resume anything" mechanism, retrying individual steps (a failed step -either stops, continues, or dead-letters the whole run — no per-step -retry in v1). - -## Decisions - -### Decision 1: `flow` OR schema shape - -```jsonc -{ - "slug": "flow", - "properties": { - "uuid": { "type": "string", "title": "UUID" }, - "name": { "type": "string" }, - "description": { "type": "string" }, - "isEnabled": { "type": "boolean", "default": true }, - "steps": { - "type": "array", - "title": "Steps", - "description": "Ordered list of flow steps. Order is the array index's `order` field, not array position, so branch targets (nextStepOrder) remain stable across insert/delete/reorder edits.", - "items": { - "type": "object", - "required": ["order", "type", "onError"], - "properties": { - "order": { "type": "integer", "description": "Stable step identifier and default execution sequence (ascending). Branch nextStepOrder/defaultNextStepOrder reference this, not array position." }, - "type": { "type": "string", "enum": ["call", "mapping", "synchronization", "event", "approval", "branch"] }, - "configRef": { "type": "string", "format": "uuid", "description": "Id of the existing Source (call)/Mapping (mapping)/Synchronization (synchronization)/event-target/Endpoint entity this step invokes. Not applicable to branch steps." }, - "condition": { "type": "object", "description": "Optional JsonLogic rule. Step runs only if apply(condition, context) == true. Absent/empty = always run." }, - "onError": { "type": "string", "enum": ["stop", "continue", "dead_letter"], "default": "stop" }, - "branches": { - "type": "array", - "description": "branch steps only. Evaluated in array order, first match wins.", - "items": { - "type": "object", - "properties": { - "condition": { "type": "object" }, - "nextStepOrder": { "type": "integer" } - } - } - }, - "defaultNextStepOrder": { "type": "integer", "description": "branch steps only. Used if no branches[].condition matches." } - } - } - } - } -} -``` - -**Rationale:** `order` is a stable integer field on each step object -(not array position) precisely so `branch` steps can jump to a specific -step even after the author reorders/inserts/deletes other steps in the -editor — mirrors how `rule.order` already works for endpoint rules -(rules are matched/sorted by an `order` field, not array index, per -`rule-pipeline` REQ-RULE-001). **Alternative considered:** array-index -addressing (branch target = index) — rejected because it makes the -step-list editor's insert/delete/reorder operations silently corrupt -branch targets. - -### Decision 2: FlowRunnerService execution model — reusing FlowToken as-is - -`flow-token-helper`'s `FlowToken` is **not** a generic named-slot store -— it has exactly 8 fixed properties (`requestOriginal`/`Amended`, -`responseOriginal`/`Amended`, `syncInputOriginal`/`Amended`, -`syncOutputOriginal`/`Amended`). The context-brief's "reuse the slot -mechanism" is honored literally, not reinvented, by treating the flow's -step-to-step data channel as `syncInputAmended`/`syncOutputAmended`: - -- Before each step runs, `FlowRunnerService` sets - `$flowToken->setSyncInputAmended($previousStepOutput ?? $initialInput)`. -- After a step produces a result, `FlowRunnerService` sets - `$flowToken->setSyncOutputAmended($stepResult)`, which becomes the next - step's `syncInputAmended`. -- `requestOriginal`/`responseOriginal` are seeded once at flow start from - the trigger context (the endpoint request, for an endpoint-triggered - flow; empty array for cron/event/manual triggers) and left largely - untouched — they exist so a `call`/`mapping` step's JsonLogic condition - can still reference `request.parameters.x` if the flow was - endpoint-triggered, exactly as an endpoint rule's condition can today. -- No `__unserialize()` exists on `FlowToken` (confirmed gap, shared with - `ApprovalService::rehydrateFlowToken()`); `FlowRunnerService` rehydrates - the same way `ApprovalService` does — construct empty, then call the 8 - setters from a stored snapshot array. - -**Alternative considered:** wrap `FlowToken` in a new -`FlowExecutionContext` with a generic `array $slots` map keyed by step -`order`, giving every step's full output history, not just the -immediately-preceding step's. Rejected for v1: it duplicates -`FlowToken` rather than reusing it (violating the brief's explicit "do -NOT duplicate" constraint), and no v1 step type needs anything but its -immediate predecessor's output — `synchronization`/`mapping`/`call` are -single-hop transforms. Noted as a v2 follow-up if a real use case for -"read step 1's output from step 4" emerges. - -### Decision 3: dispatch — thin adapter methods, not reimplementation - -`FlowRunnerService` gets one `private function runStep(array $step, -FlowToken &$flowToken, ObjectEntity $flowRun): array` with a `match` on -`$step['type']`, each arm resolving `configRef` to the target entity via -`OrObjectService::find()` and calling exactly one existing public method: - -| step type | resolves `configRef` as | calls | -|---|---|---| -| `call` | Source id | `CallService::call($source, ...)` | -| `mapping` | Mapping id | `MappingService::executeMapping($mapping, $flowToken->getSyncInputAmended())` | -| `synchronization` | Synchronization id | `SynchronizationService::synchronize($synchronization, data: $flowToken->getSyncInputAmended(), flowToken: $flowToken)` — passes `$flowToken` through by reference so `sync-safety`'s guards (batch-approval gate, dedup, etc.) run exactly as they do for a directly-triggered sync; a flow step MUST NOT set any flag that bypasses those guards | -| `event` | n/a (config carries source/subject/type template) | `EventService::emitCloudEvent(type, source, subject, $flowToken->getSyncInputAmended())` | -| `approval` | n/a (config carries approverGroup/onReject/onTimeout) | writes an `approval_request` OR object with `flowRunId`/`resumeStepOrder` set (new fields, alongside the existing `endpointId`/`synchronizationId` — see migration note) and returns a `suspended` run status | -| `branch` | n/a | evaluates `branches[]` conditions via `JsonLogic::apply()`, returns the selected `nextStepOrder` to the runner's loop control (no service call) | - -This table **is** the reuse contract: no step arm contains -source-calling, mapping-transform, or sync-batching logic — it only -resolves an id and forwards to the existing method signature (verified -against HEAD in the accompanying research: `CallService::call()`, -`MappingService::executeMapping()`, `SynchronizationService::synchronize()` -line 2055, `EventService::emitCloudEvent()`). - -### Decision 4: approval-step suspend/resume - -No generic "suspend/resume anything" primitive exists in -`ApprovalService` — `EndpointService::resumeFromApproval()` and -`SynchronizationService`'s gate-resume path are both bespoke. The flow -approval step follows the same house style: - -- **Suspend:** `FlowRunnerService` calls - `ApprovalService::suspend()`-equivalent persistence: create an - `approval_request` OR object with `flowRunId` (new field) and - `resumeStepOrder` = the approval step's `order` + 1's-worth of context - (the step immediately after the approval step to resume from), - `approverGroup`/`onReject`/`onTimeout` from the step's config, `snapshot` - = `$flowToken->__serialize()` (sensitive-header-stripped, matching the - existing `suspend()` precedent). The `flow_run` object's status is set - to `suspended`. -- **Resume:** `ApprovalsController::approve()`'s existing branch-on-record-shape - logic (today: `endpointId` vs `synchronizationId`) gets a third branch: - `flowRunId` set → `FlowRunnerService::resumeFromApproval(ObjectEntity - $approvalRequest): ObjectEntity` — rehydrates `FlowToken` via - `ApprovalService::rehydrateFlowToken()` (reused as-is, no fork), loads - the `flow_run`/`flow`, and re-enters the step loop starting at - `resumeStepOrder`, exactly mirroring how `EndpointService - ::resumeFromApproval()` re-enters `processRules()` after - `resumeAfterOrder`. -- **Reject/timeout:** `ApprovalService::reject()`/`sweepExpired()` are - reused unmodified; the flow-specific piece is only in - `ApprovalsController`'s branch and `FlowRunnerService`'s new resume - method — the flow_run's status is set to `stopped` (rejected) or - `dead_letter` (expired, per the approval step's `onTimeout` config, - matching the endpoint-rule case's `onTimeout` semantics). - -**Migration note:** `approval_request` gains two new optional properties -(`flowRunId`, `resumeStepOrder`) — additive, no existing property -changes; see migration section below on why this is a register.d fragment -addition, not a NC schema migration. - -### Decision 5: `event` step targets EventService's CloudEvents pipeline, not raw IEventDispatcher - -Two event mechanisms exist in this codebase: `EventService -::emitCloudEvent()` (the app's own CloudEvents/subscription delivery -pipeline, itself OR-persisted and inspectable) and raw -`OCP\EventDispatcher\IEventDispatcher::dispatchTyped()` (fired -imperatively from code, e.g. `SynchronizationDeletionGuardedEvent`). A -flow step's config is declarative (source/subject/type strings an admin -fills in), which only `emitCloudEvent()` supports — raw NC events are -typed PHP event objects with no declarative constructor shape. **Decision: -v1 `event` steps call `EventService::emitCloudEvent()` only.** Firing an -internal NC event from a flow step is out of scope; noted as a v1 -limitation, not a follow-up promise (no known use case yet). - -### Decision 6: per-step trace — own minimal schema now, converge later - -`execution-trace-observability` (sibling change, context-brief only, no -landed spec) proposes riding `FlowToken`'s 8-slot snapshots for a -cross-cutting `execution_trace` schema. Since it has not landed, this -change ships its own minimal `flow_run`/`flow_run_log` schemas now -(status, timing, error per step) rather than blocking on another -in-flight, spec-only change. If `execution-trace-observability` lands -first, a small follow-up change converges `flow_run_log` onto -`execution_trace` (or documents why they stay separate) — flagged -explicitly in tasks.md as a deferred item, not silently dropped. - -### Decision 7: step-list editor UI — typed pages, no canvas - -`FlowDetailPage.vue` (custom component, following the -`SynchronizationDetailPage` precedent — `type: "custom"` in -`src/manifest.json` with a `_note` justifying the bespoke component, -since the generic `detail` widget-grid page cannot express an ordered, -reorderable step list) renders: - -- Flow metadata (name, description, isEnabled) via the standard - generic-detail-equivalent form fields. -- A step list: each row = type `NcSelect` (`inputLabel: 'Step type'`, - following `EditEndpoint.vue`'s conformant pattern, NOT - `EditSynchronization.vue`'s), a config-ref picker (`NcSelect`, - `inputLabel` set, options filtered to the entity list matching the - chosen type — Sources for `call`, Mappings for `mapping`, etc.), a - condition editor field (JSON/JsonLogic textarea — reuse whatever - editor the Rules page already uses for rule conditions, do not build a - new one), an onError `NcSelect` (`inputLabel: 'On error'`), and — for - `branch` rows only — a nested branches sub-list (condition + target - step-order picker) + a default-target picker. -- Row controls: move up / move down / remove (simple array reorder, not - drag-and-drop — no `NcListItem` drag handles, no SortableJS - dependency). -- A "Run" header action (manual trigger) that calls - `FlowRunnerService::run()` via a new `POST - /api/flows/{id}/run` endpoint and surfaces the resulting `flow_run`'s - status/log, following the same pattern as `JobsController::run()`. -- A `FlowRunLog` sub-view (or a tab on the detail page) listing past - `flow_run`/`flow_run_log` records, mirroring the `Job logs` / - `SyncDeadLetters` list pattern already in the manifest. - -New modal(s) live at `src/modals/Flow/` (e.g. `EditFlow.vue` for the -name/description/isEnabled fields, matching the `src/modals//` -convention) — no inline modal markup in `FlowDetailPage.vue` itself. - -**Follow-up (explicitly deferred, not silently dropped):** a -drag-and-drop node-graph canvas for step editing/branch visualization is -the natural v2 evolution once the declarative model has real usage data; -file a GitHub issue at archive time rather than scope-creeping v1. - -## Risks / Trade-offs - -- [Risk] `FlowToken`'s single-predecessor data channel (Decision 2) - can't express "step 4 needs step 1's raw output, not step 3's" → - [Mitigation] documented v1 limitation; a step needing earlier data - should have its `mapping` step assemble what later steps need, or this - becomes a v2 `FlowExecutionContext` follow-up once a concrete case - exists. -- [Risk] `branch` steps reference `nextStepOrder` by integer — a step - deleted from the editor without updating branch targets silently - dead-ends a flow (the runner would fail to find that `order` and must - treat it as a `stop`-policy error) → [Mitigation] the step-list editor - MUST validate on save that every `nextStepOrder`/`defaultNextStepOrder` - referenced by a `branch` step resolves to an existing step `order` - (client-side validation task in tasks.md); the runner MUST also - defensively treat an unresolvable `nextStepOrder` as a fatal run error - (not a silent skip) so a bad edit fails loudly, not silently. -- [Risk] Sequential-only execution (no fan-out) means a flow with N - independent `call` steps runs them one at a time, N× the latency of - doing them concurrently → [Mitigation] explicitly out of scope per the - brief; documented as a known v1 performance ceiling, not a bug. -- [Risk] `approval` step's suspend/resume duplicates - `EndpointService::resumeFromApproval()`'s shape as a third bespoke - resume path rather than factoring out a shared helper → - [Mitigation] accepted: the existing two paths (endpoint, synchronization) - are already not shared, so a third following the identical pattern is - consistent with house style, not a new inconsistency; refactoring - all three into one generic resume mechanism is out of scope for this - change. - -## Migration Plan - -No Nextcloud schema migration (`lib/Migration/`) is needed — see -`migration.md` (skipped, reason recorded there): this app persists -domain entities as OpenRegister objects defined via `lib/Settings/ -register.d/*.json` fragments (confirmed pattern — `hitl-approval-rule-action.json` -is the direct precedent), not Doctrine tables. Deploying this change -means: (1) merge the new `flow`/`flow_run`/`flow_run_log` -register.d fragment(s) + the additive `approval_request` field extension -into the OR register via the existing AppHost repair-step mechanism (no -manual SQL); (2) ship the new `EndpointService::processRules()` match -arm, `FlowAction`, `FlowRunnerService`, controller, and manifest entries -in the same PR — all additive, no existing behavior changed. Rollback = -revert the PR; orphaned `flow`/`flow_run` OR objects are inert (see -proposal.md Rollback Strategy). - -## Seed Data - -### Schema: `flow` - -| Field | Object 1 | Object 2 | Object 3 | -|-------|----------|----------|----------| -| slug | `onboarding-partner-sync` | `invoice-approval-flow` | `catalog-refresh-branch` | -| name | Onboarding partner sync | Invoice approval flow | Catalog refresh with branch | -| description | Calls the partner API, maps the response, and syncs it into the register | Maps an inbound invoice, requires finance approval, then synchronizes | Branches to a full or incremental sync depending on a condition | -| isEnabled | true | true | true | -| steps | `call`(order 10)→`mapping`(order 20)→`synchronization`(order 30), each `onError: stop` | `mapping`(order 10, `onError: stop`)→`approval`(order 20, `onError: stop`)→`synchronization`(order 30, `onError: dead_letter`) | `mapping`(order 10)→`branch`(order 20, branches to order 30 or 40)→`synchronization`(order 30, full)→`synchronization`(order 40, incremental) | - -**Related items per object:** none (flows reference existing seeded -Source/Mapping/Synchronization objects by id — no separate files/notes/ -tasks/contacts). Seed `configRef` values MUST resolve to already-seeded -Source/Mapping/Synchronization objects in the same install (wire up -against whichever seed Sources/Mappings/Synchronizations the base -`openconnector` seed already provides — do not invent new placeholder -entities just for flow steps). - -## Trade-offs - -Considered building `FlowRunnerService` as a new interpreter for -JsonLogic-described DAGs (general graph, arbitrary edges) instead of an -ordered array with a single `branch` escape hatch. Rejected: the brief -is explicit that v1 is "declarative pipeline, NOT drag canvas," and a -general DAG model pulls in exactly the fan-out/loop complexity that is -explicitly out of scope — the ordered-array-plus-branch model is the -minimum structure that supports "3-step linear" and "branch to A or B" -without opening the door to unbounded graph complexity prematurely. diff --git a/openspec/changes/visual-flow-orchestration/proposal.md b/openspec/changes/visual-flow-orchestration/proposal.md index a4ca88f4e..c885615cd 100644 --- a/openspec/changes/visual-flow-orchestration/proposal.md +++ b/openspec/changes/visual-flow-orchestration/proposal.md @@ -1,246 +1,54 @@ -# Proposal: visual-flow-orchestration - -## Summary - -Integriq has no multi-step workflow entity: a `synchronization` is one -source → one target → one mapping, and chaining steps together today only -happens indirectly through endpoint rules or events. This change adds a -`flow` OpenRegister entity — an ordered list of steps, each referencing an -existing Source/Mapping/Synchronization/Endpoint/Approval by id, with an -optional JsonLogic `condition` and an `onError` policy — plus a -`FlowRunnerService` that executes those steps in order by calling the -existing `CallService`/`MappingService`/`SynchronizationService`/ -`EventService`/`ApprovalService` APIs. This closes the multi-step -orchestration gap identified against n8n/Windmill/Make/NiFi (Specter -insight #1249) without building a new execution engine or a drag-and-drop -canvas. - -**Disambiguation:** this is NOT the same as the sibling change -`flow-workflowengine-integration`, which registers Integriq operations -as adapters inside Nextcloud core's own `files_workflowengine` "Flow" UI. -This change introduces Integriq's own `flow` entity and runner; the two -share the word "flow" but touch unrelated code paths and have no scope -overlap. - -## Motivation - -Competitors model multi-step pipelines as first-class entities with -per-step logs and branching. Today, an Integriq admin who wants -"call an external API, map the result, then synchronize it into a -register" has to either write three separate endpoint rules glued together -through the `synchronization`/`mapping`/`extend_external_input` rule types, -or accept that each step is triggered and observed independently with no -shared run identity. This is workable for two-step chains but breaks down -past that, and there is no way to express "run step 3 only if step 2's -result satisfies a condition" or "branch to step 4a or 4b depending on the -payload" without hand-rolling it inside a single rule's JsonLogic -condition. A lightweight, declarative, backend-first `flow` entity closes -this gap while deliberately NOT attempting to match a general-purpose -workflow engine's feature set. - -## Affected Projects - -- [x] Project: `integriq` — new `flow` OR entity + `FlowRunnerService` - + `flow` rule-pipeline action type + `flow` job Action + Flows index/detail - UI. - -## Scope - -### In Scope - -- A `flow` OpenRegister schema: `name`, `description`, `isEnabled`, and an - ordered `steps[]` array. Each step has `order`, `type` (`call` | - `mapping` | `synchronization` | `event` | `approval` | `branch`), - `configRef` (the id of the existing Source/Mapping/Synchronization/ - Endpoint/Approval-group entity the step invokes), an optional JsonLogic - `condition` (run-if, evaluated against the shared step context), an - `onError` policy (`stop` | `continue` | `dead_letter`), and, for `branch` - steps only, a `branches[]` list of `{ condition, nextStepOrder }` pairs - evaluated in order (first match wins) plus a `defaultNextStepOrder`. -- `FlowRunnerService::run(ObjectEntity $flow, array $input = [], - ?string $triggerSource = null): ObjectEntity` — executes a flow's steps - in `order`, threading a `FlowToken` (reused as-is from - `flow-token-helper` — see design.md for exactly how its 8 fixed slots - map onto step input/output, since it is not a generic named-slot store) - as the shared context between steps, calling the existing - `CallService::call()`, `MappingService::executeMapping()`, - `SynchronizationService::synchronize()`, `EventService::emitCloudEvent()`, - and `ApprovalService::suspend()`/resume path for the matching step types. - No step type reimplements the logic of the entity it calls. -- A `branch` step type: evaluates each `branches[].condition` - (JsonLogic, via the same `JWadhams\JsonLogic::apply()` call already used - by `EndpointService::checkRuleConditions()`) in order against the - current context and jumps to the first matching `nextStepOrder`, or - `defaultNextStepOrder` if none match. This is the only non-linear - control flow in v1 — no fan-out, no loops. -- An `approval` step type that suspends the flow run mid-execution by - delegating to `ApprovalService::suspend()`-equivalent persistence, and - resumes via a new `FlowRunnerService::resumeFromApproval()` entrypoint - analogous to `EndpointService::resumeFromApproval()` (see design.md — - nothing generic exists in `ApprovalService` for "resume any suspended - thing"; each caller writes its own resume path). -- Per-step execution trace: a `flow_run` OR object (one per run) with an - embedded/related `flow_run_log` array of per-step entries - (`stepOrder`, `type`, `status`, `startedAt`, `finishedAt`, `error?`). - If `execution-trace-observability` lands first, `flow_run_log` SHOULD - compose with its `execution_trace` schema instead of duplicating it — - noted as a sequencing dependency, not a hard blocker (see design.md). -- Triggers: a flow runs via (a) a new `flow` job Action (`lib/Action/ - FlowAction.php`, `jobClass` on a `job` OR object — cron), (b) a new - `flow` rule-pipeline action type in `EndpointService::processRules()` - (endpoint rule), (c) a new `EventService`/event-subscriber hook that - triggers a flow on a CloudEvent (event), and (d) a manual "Run" button - on the Flow detail page calling `FlowRunnerService::run()` directly - (manual). All four reuse existing trigger surfaces — no new scheduler. -- A Flows index page (`type: index`, generic list, matching the - `Synchronizations` pattern) and a Flow detail page - (`type: custom`, component `FlowDetailPage`, matching the - `SynchronizationDetail` pattern) with a typed step-list editor: an - ordered list of step rows, each with a type dropdown (NcSelect with - `inputLabel`, following `EditEndpoint.vue`'s pattern — NOT - `EditSynchronization.vue`'s non-conformant one), a config-ref picker - scoped to the chosen type's entity list, a condition editor (reusing - whatever condition-editing UI the rule pipeline already has), an - onError select, and up/down/remove step controls. This is a list, not - a canvas — no drag-and-drop, no node graph. -- Unit tests for `FlowRunnerService`: step ordering, condition skip, - branch step next-step selection, `onError: stop|continue|dead_letter`, - approval-step suspend/resume. Integration test: a 3-step flow - (`call` → `mapping` → `synchronization`) runs end-to-end and produces - a correctly-ordered `flow_run_log`. - -### Out of Scope - -- **Visual drag-and-drop canvas UI.** V1 ships a typed step-list editor - only. A canvas is a natural v2 follow-up once the declarative model is - proven; file a follow-up issue at archive time. -- **Parallel/fan-out steps.** V1 is strictly sequential-with-branch; a - step has exactly one active predecessor at runtime (branch selects ONE - next step, it does not spawn concurrent branches). Fan-out/join - semantics are deferred. -- **Loops/iteration steps.** No `for-each` or `while` step type in v1. A - step that needs to process N items does so by delegating to a - `synchronization` step (which already has its own internal batching), - not by the flow runner looping. -- Reimplementing Source/Mapping/Synchronization/Endpoint/Approval logic - inside the runner — steps are thin references, not copies. -- A generic "resume any suspended thing" mechanism in `ApprovalService` - — the flow approval step gets its own resume entrypoint, matching the - existing precedent that `EndpointService` and `SynchronizationService` - each have their own resume path. - -## Approach - -Add a `flow` OpenRegister schema (register.d fragment, following the -`hitl-approval-rule-action.json` precedent) plus `flow_run`/`flow_run_log` -schemas for execution history. Add `FlowRunnerService` in `lib/Service/` -that resolves a flow's `steps[]`, iterates them in `order`, evaluates -`condition`/`branches` via the existing static `JWadhams\JsonLogic::apply()` -call, and dispatches each step's `type` to the corresponding existing -service's public entrypoint — no new execution logic beyond sequencing, -context-threading, and error-policy handling. Add one new `match` arm -(`'flow' => ...`) to `EndpointService::processRules()`'s existing 22-way -type dispatch, following the exact precedent set by the `approval` action -type (REQ-RULE-008). Add `lib/Action/FlowAction.php` implementing the -same duck-typed `run(array $arguments): array` contract as -`SynchronizationAction`/`PingAction`, resolved via `jobClass` on a `job` OR -object — no new Action interface needed. Add `Flows`/`FlowDetail` manifest -entries following the `Synchronizations`/`SynchronizationDetail` -generic-index + custom-detail split already used for comparably complex -entities. - -## New Dependencies - -None. Reuses `jwadhams/json-logic-php` (already a dependency, used for -rule conditions) and all existing Integriq services. - -## Impact - -- `lib/Settings/register.d/` — new fragment(s) defining `flow`, `flow_run`, - `flow_run_log` schemas. -- `lib/Service/EndpointService.php` — one new `match` arm in - `processRules()`. -- `lib/Service/FlowRunnerService.php` — new file. -- `lib/Action/FlowAction.php` — new file. -- `lib/Controller/FlowsController.php` (or equivalent) — new file, CRUD + - manual-run endpoint, following existing controller conventions. -- `src/manifest.json` — new `Flows`/`FlowDetail` page entries + menu entry. -- `src/views/Flow/` — new `FlowDetailPage.vue` + step-list editor - sub-components; `src/modals/Flow/` — new modal(s). -- `openspec/specs/flow-orchestration/spec.md` — new capability spec. -- `openspec/specs/rule-pipeline/spec.md` — delta (`flow` action type, - REQ-RULE-009). -- `openspec/specs/job-management/spec.md` — delta (flow job action, - REQ-JOB-003). - -## Cross-Project Dependencies - -None outside `integriq`. Within `integriq`, this change -references (but does not modify) `flow-token-helper`, `hitl-approval` -(`ApprovalService`), `rule-pipeline`, `job-management`/`job-scheduling`, -`synchronization-engine`, and `openconnector-app-manifest`. It SHOULD -sequence after `execution-trace-observability` if that change lands first -(see design.md); if it lands first, `flow_run_log` ships as its own -minimal schema and a follow-up change converges the two. - -## Risks - -### Risk 1: FlowToken is not a generic slot store - -**Severity:** Medium — **Mitigation:** `flow-token-helper`'s `FlowToken` -has 8 fixed, hardcoded slots (request/response/syncInput/syncOutput × -original/amended), not an extensible key-value context. The brief assumed -a generic slot mechanism; it does not exist. Design.md defines exactly how -the runner maps step input/output onto the existing 8 slots (treating -`syncInputAmended`/`syncOutputAmended` as the primary step-to-step -data channel) rather than inventing a second, competing context object. - -### Risk 2: Approval resume has no generic mechanism to reuse - -**Severity:** Medium — **Mitigation:** `ApprovalService` provides -suspend/rehydrate/complete primitives but each caller (`EndpointService`, -`SynchronizationService`) writes its own bespoke resume path. The flow -approval step follows the same pattern with its own -`FlowRunnerService::resumeFromApproval()`, persisting the suspending -step's `order` as `resumeOrder` (mirroring the endpoint-rule case -exactly) rather than trying to generalize `ApprovalService` itself, which -is out of scope for this change. - -### Risk 3: execution-trace-observability may not land first - -**Severity:** Low — **Mitigation:** ship a minimal, self-contained -`flow_run`/`flow_run_log` schema now; if/when `execution-trace-observability` -lands, converge in a small follow-up change rather than blocking this one -on another in-flight, spec-only change. - -### Risk 4: naming collision with flow-workflowengine-integration - -**Severity:** Low — **Mitigation:** explicit disambiguation note in this -proposal and in the new capability spec's Purpose section; no code-level -conflict exists (verified — that change touches `files_workflowengine` -adapters, not any file this change touches). - -## Rollback Strategy - -The `flow` entity, `FlowRunnerService`, `FlowAction`, and Flows UI are -wholly additive and isolated behind their own routes/schemas/action type. -Rollback = revert the PR(s); the new `flow` match arm in -`EndpointService::processRules()` is a single additive `case`, and no -existing dispatch, schema, or service method is modified. Any `flow` -OR objects and `flow_run`/`flow_run_log` history left behind after a -revert are inert (no other code reads them) and can be cleaned up via a -standard OR object deletion pass; no destructive migration is introduced -in either direction. - -## Open Questions - -- Should the `event` step type target `EventService::emitCloudEvent()` - (the app's own CloudEvents subscriber pipeline) or raw - `OCP\EventDispatcher\IEventDispatcher` (internal NC events), or should - the step config let the author pick? Deferred to design.md Decision 5 — - default is CloudEvents-only in v1, since that's the mechanism a flow - step config can name declaratively (a source/subject/type), while raw - NC events are typically fired from code, not configuration. -- Exact retention policy for `flow_run`/`flow_run_log` (mirrors - `job_log`'s per-record `expires` + cleanup task, or a fixed retention?) - — resolved in design.md, follows the `job_log` precedent. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: visual-flow-orchestration (superseded — re-scoped 2026-09-02) + +This directory double-counted a change that had already shipped. The wave-3 +spec commit (46dfa497, 2026-07-15 19:00) created it as a fresh 0/55 change; +the same change was implemented and archived two hours later the same evening +(`archive/2026-07-15-visual-flow-orchestration`, PR #209, 43/55 tasks checked +with per-task evidence, the rest deliberately left open for want of a live +instance). Since then the live copy has sat at 0/55, permanently inflating the +backlog with work that exists at HEAD: `FlowRunnerService`, `FlowAction`, +`FlowsController`, the `flow` rule action in `EndpointService::processRules()`, +and the `flow`/`flow_run`/`flow_run_log` schemas in +`lib/Settings/register.d/visual-flow-orchestration.json`. + +More importantly, the One-engine direction has inverted this change's premise. +It built an app-local flow engine; the fleet decision is that OpenRegister runs +the only flow engine, and Integriq contributes node types to it. The manifest +already reflects this: the `Flows` index reads OpenRegister's native flow +table and `FlowDetail` renders OR's shared `flow` page type (CnFlowEditorPage); +the step-list editor this change specified (`FlowDetailPage.vue`) no longer +exists in the tree. + +No `@spec` tag anywhere in `lib/`, `src/` or `tests/` points into this +directory (the shipped code's tags point at the archived copy), so the delta +specs, design and test plan are removed with this re-scope; they survive +verbatim in the archived twin and in git history. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `flow`/`flow_run`/`flow_run_log` schemas, `FlowRunnerService`, `FlowAction`, `flow` rule action, `FlowsController`, triggers (tasks 1-15, 20) | **Already shipped and archived**: `archive/2026-07-15-visual-flow-orchestration` (PR #209), code at HEAD | +| Flows index + detail step-list editor UI (tasks 16-19) | Shipped, then **replaced**: the manifest's `Flows`/`FlowDetail` pages now render OpenRegister's shared `flow` page type over OR's native flow table; `FlowDetailPage.vue` and the step-list editor were deleted with that cutover. Their open Playwright/verification boxes are cut — the components under test no longer exist | +| The app-local flow engine itself | **Being retired** onto OR's engine: `openspec/changes/retire-integriq-flow-schema` (schema and runner retirement, steps-to-graph migration, `occ openregister:schemas:prune-retired`), gated on `openspec/changes/integriq-flow-nodes` (`openconnector.source-call`, `openconnector.synchronization-run`, `openconnector.approval-request` contributed via `RegisterFlowNodesEvent`) | +| Synchronization-as-a-flow | `openspec/changes/flow-native-synchronization` (18/19 done): the canonical sync becomes a drawn OR flow delegating to the existing services | +| Newman tests for `/api/flows`, feature docs, screenshots, `nl_NL` strings | **Cut.** Documenting a runner scheduled for deletion is negative work; `retire-integriq-flow-schema` section 5 carries the proof obligations for the OR-backed replacement | +| v2 follow-ups (drag-and-drop canvas, fan-out, loops) | **Cut as app-local work.** OR's flow editor is the canvas; fan-out and looping are engine primitives (`FlowConcurrency`, LoopNode) and land in OpenRegister, never in a leaf app | + +## Sequencing + +`integriq-flow-nodes` first (it is fully authored and independent), then +`retire-integriq-flow-schema` (its tasks.md opens with that gate), with +`flow-native-synchronization` continuing in parallel. Nothing remains to +implement from this change directly. + +## Archival + +This directory is retired in place (not moved or renamed) so no path breaks +and no diff-scoped gate detonates. Archive it via the normal flow once the +three successor changes have shipped or been deliberately dropped. diff --git a/openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md b/openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md deleted file mode 100644 index 0fa8e4146..000000000 --- a/openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md +++ /dev/null @@ -1,428 +0,0 @@ -# flow-orchestration Specification - -**Status**: planned -**Scope**: integriq -**OpenSpec changes**: -- visual-flow-orchestration (this change) - -## Purpose - -Provides a lightweight, declarative multi-step pipeline entity (`flow`) for -Integriq: an ordered list of steps, each referencing an existing -Source/Mapping/Synchronization/Endpoint/Approval by id, with an optional -JsonLogic run-if condition, a single-target `branch` step for non-linear -control flow, and a per-step `onError` policy. `FlowRunnerService` -executes flows by calling the existing `CallService`/`MappingService`/ -`SynchronizationService`/`EventService`/`ApprovalService` public -entrypoints — it does not reimplement any of their logic (ADR-008 -Controller → Service → Mapper layering; OpenRegister is the persistence -layer for every entity per ADR governing OR as the required runtime -dependency). This closes the "no multi-step workflow entity" competitive -gap (Specter insight #1249) without building a general-purpose workflow -engine or a drag-and-drop canvas — see the `visual-flow-orchestration` -proposal for the explicit v1/v2 scope boundary (no canvas, no -parallel/fan-out, no loops in v1). - -**Disambiguation:** this capability is unrelated to `flow-workflowengine-integration` -(a separate, sibling change that registers Integriq operations as -adapters inside Nextcloud core's own `files_workflowengine` UI). Both use -the word "flow"; neither touches the other's code. - -## ADDED Requirements - -### Requirement: Flow steps execute sequentially in `order` (REQ-001) - -The system MUST provide a `flow` OpenRegister schema (register -`openconnector`, schema `flow`) with an ordered `steps[]` array, each step -carrying a stable integer `order` field (not array position). -`FlowRunnerService::run(ObjectEntity $flow, array $input = [], ?string -$triggerSource = null): ObjectEntity` MUST resolve `$flow->getObject()['steps']`, -sort by `order` ascending, and execute each step in that sequence by -dispatching to the step's `type` (`call` | `mapping` | `synchronization` | -`event` | `approval` | `branch`), resolving `configRef` to the referenced -Source/Mapping/Synchronization/Endpoint/Approval-group entity and calling -that entity's existing service method — `CallService::call()` for `call`, -`MappingService::executeMapping()` for `mapping`, -`SynchronizationService::synchronize()` for `synchronization`, -`EventService::emitCloudEvent()` for `event`. No step type's dispatch MUST -reimplement the logic of the service it calls. - -@e2e exclude backend flow execution engine — covered by PHPUnit, not browser UI - -#### Scenario: a 3-step flow (call → mapping → synchronization) runs in order - -- **GIVEN** a `flow` with three steps: order 10 `type: call` (Source X), - order 20 `type: mapping` (Mapping Y), order 30 `type: synchronization` - (Synchronization Z), each with `onError: stop` -- **WHEN** `FlowRunnerService::run($flow)` is called -- **THEN** `CallService::call()` is invoked first, its result becomes the - input to `MappingService::executeMapping()`, and that mapped result - becomes the input to `SynchronizationService::synchronize()`, invoked - last -- **AND** the resulting `flow_run`'s `flow_run_log` contains exactly three - entries in `stepOrder` order 10, 20, 30, each `status: completed` - -#### Scenario: steps run in `order` value, not array position - -- **GIVEN** a `flow` whose `steps[]` array lists the order-30 step before - the order-10 step (out-of-position insertion) -- **WHEN** `FlowRunnerService::run($flow)` is called -- **THEN** execution still proceeds order 10 then order 30 (sorted by the - `order` field, independent of array position) - -### Requirement: Step context is threaded via the reused FlowToken (REQ-002) - -`FlowRunnerService` MUST reuse `flow-token-helper`'s `FlowToken` as the -step-to-step data channel rather than introducing a second, competing -context object. Before each step runs, the system MUST set -`$flowToken->setSyncInputAmended()` to the previous step's output (or the -flow's initial `$input` for the first step); after a step produces a -result, the system MUST set `$flowToken->setSyncOutputAmended()` to that -result, which becomes the next step's `syncInputAmended`. For an -endpoint-triggered flow, `requestOriginal`/`responseOriginal` MUST be -seeded once at flow start from the triggering request so step conditions -MAY reference `request.parameters.*`, matching how an endpoint rule's -JsonLogic condition can already do so. - -@e2e exclude backend flow context threading — covered by PHPUnit, not browser UI - -#### Scenario: a step's output becomes the next step's input - -- **GIVEN** a 2-step flow where the order-10 `mapping` step returns - `{ "id": "abc" }` -- **WHEN** the order-20 `synchronization` step runs -- **THEN** `SynchronizationService::synchronize()` is called with - `data: { "id": "abc" }` (read from `$flowToken->getSyncInputAmended()`) - -### Requirement: Step `condition` skips a step when it evaluates false (REQ-003) - -The system MUST evaluate each step's optional JsonLogic `condition` -before dispatching that step, calling `JWadhams\JsonLogic::apply($step['condition'], -$context)` — the same static call already used by -`EndpointService::checkRuleConditions()` for endpoint rules — against the -current step context. If the condition evaluates to a value that does not -loosely equal `true`, the step MUST be skipped (not dispatched, no -downstream service called) and recorded in `flow_run_log` with -`status: skipped`. A step with no `condition` (absent or empty) MUST -always run. - -@e2e exclude backend condition evaluation — covered by PHPUnit, not browser UI - -#### Scenario: a false condition skips the step - -- **GIVEN** a flow's order-20 step has `condition: { "==": [{"var": - "syncInputAmended.status"}, "active"] }` and the order-10 step's output - is `{ "status": "inactive" }` -- **WHEN** the runner reaches the order-20 step -- **THEN** the step's service method is NOT called -- **AND** `flow_run_log` records a `stepOrder: 20, status: skipped` entry -- **AND** execution continues to the next step in sequence - -#### Scenario: a true condition runs the step normally - -- **GIVEN** the same flow but the order-10 step's output is - `{ "status": "active" }` -- **WHEN** the runner reaches the order-20 step -- **THEN** the step's service method IS called and `flow_run_log` records - `status: completed` (or `failed`, per REQ-005) for that step - -### Requirement: `branch` step selects the next step via JsonLogic (REQ-004) - -A step of `type: branch` MUST carry a `branches[]` array of -`{ condition, nextStepOrder }` pairs and MAY carry a -`defaultNextStepOrder`. The system MUST evaluate each `branches[].condition` -in array order via `JWadhams\JsonLogic::apply()` against the current step -context and, on the first match, set the next step to execute to that -entry's `nextStepOrder`, skipping any steps between the branch step and -the selected target. If no `branches[].condition` matches, the system -MUST use `defaultNextStepOrder` if present, or continue to the next step -in `order` sequence otherwise. A `branch` step's `nextStepOrder` or -`defaultNextStepOrder` that does not resolve to an existing step `order` -MUST cause the flow run to fail with a fatal error (not a silent skip), -regardless of any step's individual `onError` policy — an unresolvable -branch target is a configuration error, not a runtime step failure. - -@e2e exclude backend branch step evaluation — covered by PHPUnit, not browser UI - -#### Scenario: branch selects the first matching target - -- **GIVEN** a `branch` step at order 20 with - `branches: [{ condition: {"==":[{"var":"syncInputAmended.mode"},"full"]}, nextStepOrder: 30 }, { condition: {"==":[{"var":"syncInputAmended.mode"},"incremental"]}, nextStepOrder: 40 }]` - and the order-10 step's output is `{ "mode": "incremental" }` -- **WHEN** the runner reaches the order-20 branch step -- **THEN** execution jumps to the order-40 step -- **AND** the order-30 step is NOT executed and does NOT appear in - `flow_run_log` - -#### Scenario: no branch matches falls back to defaultNextStepOrder - -- **GIVEN** the same branch step and the order-10 step's output is - `{ "mode": "unknown" }`, with `defaultNextStepOrder: 30` -- **WHEN** the runner reaches the order-20 branch step -- **THEN** execution proceeds to the order-30 step - -#### Scenario: an unresolvable branch target fails the run - -- **GIVEN** a `branch` step whose only `branches[].nextStepOrder` is `99` - and no step with `order: 99` exists in the flow -- **WHEN** that branch matches and is selected -- **THEN** the flow run fails with a fatal error -- **AND** `flow_run`'s status is recorded as `failed`, regardless of any - step's configured `onError` policy - -### Requirement: `approval` step suspends and resumes the flow run (REQ-005) - -A step of `type: approval` MUST suspend the flow run by persisting an -`approval_request` OR object carrying `flowRunId` and `resumeStepOrder` -(the step immediately following the approval step), `approverGroup`, -`onReject`, `onTimeout`, following the same persistence shape -`ApprovalService::suspend()` already uses for endpoint-rule suspensions, -with `snapshot` set to the current `$flowToken->__serialize()` -(sensitive-header-stripped). `FlowRunnerService::run()` MUST return -immediately after suspending, with the `flow_run`'s status set to -`suspended`; no later step MUST execute in that invocation. - -On approval, `FlowRunnerService::resumeFromApproval(ObjectEntity -$approvalRequest): ObjectEntity` MUST rehydrate the `FlowToken` via -`ApprovalService::rehydrateFlowToken()` (reused unmodified) and resume -execution at `resumeStepOrder`, continuing the same sequencing, -condition, branch, and `onError` rules as an unsuspended run. On -rejection, the flow run's status MUST be set to `stopped`. On timeout -(via `ApprovalService::sweepExpired()`'s existing cron sweep), the flow -run's status MUST be set per the approval step's `onTimeout` config, -matching the endpoint-rule case's `onTimeout` semantics. - -@e2e exclude backend approval suspend/resume — covered by PHPUnit, not browser UI - -#### Scenario: an approval step suspends the run - -- **GIVEN** a flow with an `approval` step at order 20 -- **WHEN** the runner reaches the order-20 step -- **THEN** an `approval_request` OR object is created with - `flowRunId` set to the current run and `resumeStepOrder: 30` -- **AND** `FlowRunnerService::run()` returns with `flow_run.status: suspended` -- **AND** no step after order 20 has executed - -#### Scenario: approving the request resumes the flow from the next step - -- **GIVEN** the suspended run from the previous scenario -- **WHEN** an authorized approver calls `POST /api/approvals/{id}/approve` -- **THEN** `FlowRunnerService::resumeFromApproval()` is invoked -- **AND** execution resumes at the order-30 step using the rehydrated - `FlowToken` -- **AND** the flow run's status becomes `completed` once all remaining - steps finish - -#### Scenario: rejecting the request stops the flow - -- **GIVEN** the suspended run -- **WHEN** an authorized approver calls `POST /api/approvals/{id}/reject` - with a mandatory comment -- **THEN** no further flow steps execute -- **AND** the flow run's status becomes `stopped` - -### Requirement: per-step `onError` policy governs failure handling (REQ-006) - -Each step MUST carry an `onError` policy of `stop` (default), `continue`, -or `dead_letter`. If a step's dispatched service call throws, the system -MUST catch the throwable, record `flow_run_log` for that step with -`status: failed` and the captured error message, and then: - -- `stop`: the flow run MUST end immediately with `flow_run.status: stopped`; - no later step MUST execute. -- `continue`: the flow run MUST proceed to the next step in sequence as - if the failed step had been skipped; the failure is recorded but does - not halt the run. -- `dead_letter`: the flow run MUST end immediately with - `flow_run.status: dead_letter`, distinct from `stop`, so dead-lettered - runs can be filtered/queried separately from cleanly-stopped ones (an - operator worklist, matching the existing `SyncDeadLetters` pattern). - -@e2e exclude backend error-policy dispatch — covered by PHPUnit, not browser UI - -#### Scenario: onError stop halts the run on the failing step - -- **GIVEN** a flow's order-20 step has `onError: stop` and its - `CallService::call()` throws -- **WHEN** the runner processes that step -- **THEN** `flow_run_log` records `stepOrder: 20, status: failed` -- **AND** `flow_run.status` becomes `stopped` -- **AND** no step after order 20 executes - -#### Scenario: onError continue proceeds past the failing step - -- **GIVEN** the same flow but the order-20 step has `onError: continue` -- **WHEN** the runner processes that step and it throws -- **THEN** `flow_run_log` records `stepOrder: 20, status: failed` -- **AND** the order-30 step still executes -- **AND** `flow_run.status` becomes `completed` if all remaining steps - succeed - -#### Scenario: onError dead_letter marks the run distinctly from stop - -- **GIVEN** the same flow but the order-20 step has `onError: dead_letter` -- **WHEN** the runner processes that step and it throws -- **THEN** `flow_run.status` becomes `dead_letter` (not `stopped`) -- **AND** no step after order 20 executes - -### Requirement: a flow runs via cron, endpoint rule, event, or manual trigger (REQ-007) - -The system MUST support triggering `FlowRunnerService::run()` from four -surfaces, each reusing an existing trigger mechanism rather than -introducing a new scheduler: (a) a cron-scheduled `job` OR object whose -`jobClass` is `OCA\Integriq\Action\FlowAction`, resolved and -`run($arguments)`-invoked by `JobService::executeJob()` exactly as any -other job action; (b) a `flow` rule action type added to -`EndpointService::processRules()`'s existing type dispatch, valid for -either timing, which resolves `configRef` to a `flow` id and calls -`FlowRunnerService::run($flow, data: $data)`; (c) an event-triggered -invocation wired through the existing `EventService` subscriber delivery -path, matching a configured CloudEvent type/source/subject to a `flow` id -and calling `FlowRunnerService::run()`; (d) a manual "Run" action on the -Flow detail page calling `POST /api/flows/{id}/run`, which invokes -`FlowRunnerService::run()` synchronously and returns the resulting -`flow_run`. - -@e2e exclude backend job/rule/event trigger wiring — covered by PHPUnit/Newman, not browser UI (manual-trigger UI is covered under REQ-009) - -#### Scenario: a cron job triggers a flow - -- **GIVEN** an enabled `job` OR object with `jobClass: - 'OCA\Integriq\Action\FlowAction'` and `arguments: { flowId: '' }` -- **WHEN** `JobService::run()` sweeps due jobs and calls - `FlowAction::run($arguments)` -- **THEN** `FlowRunnerService::run()` is invoked for the referenced flow -- **AND** a `job_log` entry is written summarising the flow run's outcome - -#### Scenario: an endpoint rule triggers a flow - -- **GIVEN** an endpoint with a rule of `type: flow`, `configRef: ` -- **WHEN** the endpoint's rule pipeline reaches that rule and its - condition (if any) passes -- **THEN** `FlowRunnerService::run($flow, data: $data)` is invoked with - the current pipeline data as the flow's initial input - -#### Scenario: manual run triggers a flow synchronously - -- **GIVEN** an admin viewing the Flow detail page for an enabled flow -- **WHEN** they click "Run" -- **THEN** `POST /api/flows/{id}/run` is called -- **AND** `FlowRunnerService::run()` executes synchronously and the - response carries the resulting `flow_run`'s status and `flow_run_log` - -### Requirement: flow runs are persisted with a per-step trace (REQ-008) - -Every `FlowRunnerService::run()` invocation MUST create a `flow_run` OR -object (register `openconnector`, schema `flow_run`) carrying `flowId`, -`triggerSource` (`cron` | `endpoint` | `event` | `manual`), `status` -(`running` | `completed` | `stopped` | `dead_letter` | `suspended`), -`startedAt`, `finishedAt`. Each step execution (including skipped steps, -per REQ-003) MUST append a `flow_run_log` entry with `stepOrder`, `type`, -`status` (`completed` | `skipped` | `failed`), `startedAt`, `finishedAt`, -and `error` (present only when `status: failed`). - -@e2e exclude backend trace persistence — covered by PHPUnit, not browser UI - -#### Scenario: a completed run's log reflects every step outcome - -- **GIVEN** the 3-step flow from REQ-001 where the order-20 step's - condition is false -- **WHEN** the flow run completes -- **THEN** `flow_run_log` contains three entries: order 10 `completed`, - order 20 `skipped`, order 30 `completed` -- **AND** `flow_run.status` is `completed` -- **AND** `flow_run.finishedAt` is set - -### Requirement: Flows index and detail UI provide a typed step-list editor (REQ-009) - -Integriq MUST provide a `Flows` section in its SPA: an index page -(`type: index`, listing `name`, `isEnabled`, last-run status/time) and a -detail page (`type: custom`, component `FlowDetailPage`) where an admin -can add, remove, reorder, and configure steps. Each step row MUST use an -`NcSelect` for `type` and, where applicable, `configRef` and `onError`, -each with an explicit `inputLabel` (WCAG 2.1 AA 1.3.1/4.1.2 — matching -the codebase's `EditEndpoint.vue` pattern, not `EditSynchronization.vue`'s -non-conformant one). Reordering MUST be via move-up/move-down/remove -controls — the editor MUST NOT implement drag-and-drop or a node-graph -canvas in this version. Any modal used by the Flow pages MUST live in its -own file under `src/modals/Flow/`, not inline in the page component. - -#### Scenario: Flows index page mounts and lists flows - -- **GIVEN** an authenticated admin visits the integriq app -- **WHEN** they navigate to the Flows section via the sidebar nav or - direct URL `/apps/integriq/flows` -- **THEN** the Flows index page renders inside the main content area, - listing each flow's name, enabled state, and last-run status - -#### Scenario: the step-list editor adds a step with a typed config picker - -- **GIVEN** an admin on the Flow detail page for an existing flow -- **WHEN** they click "Add step", select `type: mapping` from the step - type `NcSelect`, and then open the config-ref picker -- **THEN** the config-ref picker's options are scoped to existing - Mapping entities only (not Sources, Synchronizations, or Endpoints) - -#### Scenario: reordering uses move controls, not drag-and-drop - -- **GIVEN** a flow with three steps -- **WHEN** the admin clicks "Move up" on the second step -- **THEN** the second step's `order` value is swapped with the first - step's `order` value -- **AND** no drag-and-drop interaction is required or present on the page - -## Non-Functional Requirements - -- **Performance:** a flow run's total latency is the sum of its steps' - individual latencies (sequential execution, REQ-001) — this is a known - v1 ceiling, not a defect; a flow with steps that could run concurrently - is out of scope for parallelization in this version. -- **Accessibility:** the step-list editor MUST meet WCAG 2.1 AA, - specifically 1.3.1 (Info and Relationships) and 4.1.2 (Name, Role, - Value) via `NcSelect`'s `inputLabel` prop on every select control - (REQ-009). -- **Internationalization:** all Flow UI strings and flow-related error - messages MUST be localisable (Dutch + English, hydra ADR-007); - i18n keys MUST be written in English per company convention. - -## Acceptance Criteria - -- [ ] A 3-step flow (`call` → `mapping` → `synchronization`) executes - end-to-end via `FlowRunnerService::run()` and produces an ordered - `flow_run_log`. -- [ ] A step `condition` that evaluates false skips that step without - calling its underlying service. -- [ ] A `branch` step selects its next step via JsonLogic evaluation of - `branches[]`, falling back to `defaultNextStepOrder`. -- [ ] An `approval` step suspends the run via `ApprovalService`-backed - persistence and resumes correctly on approval. -- [ ] `onError: stop | continue | dead_letter` each produce the - documented distinct `flow_run.status`. -- [ ] A flow can be triggered via cron job, endpoint rule, event, and - manual "Run" — all four reuse existing trigger surfaces. -- [ ] The Flows index/detail UI renders with a typed step-list editor - (no drag-and-drop canvas) and passes `hydra-gate-nc-input-labels` / - `hydra-gate-modal-isolation`. - -## Notes - -- Explicitly out of scope for v1 (see proposal.md): a drag-and-drop - canvas UI, parallel/fan-out steps, and loop/iteration step types. Each - is a candidate v2 follow-up once the declarative model has real usage - data — file follow-up issues at archive time rather than scope-creeping - this change. -- `FlowToken` (flow-token-helper) is reused as-is — it is a fixed - 8-slot snapshot container, not a generic named-slot store; see design.md - Decision 2 for exactly how step input/output map onto its - `syncInput`/`syncOutput` slots, and the accepted v1 limitation that a - step can only see its immediate predecessor's output. -- `flow_run`/`flow_run_log` are a minimal, self-contained schema pair for - v1. If `execution-trace-observability` (sibling, spec-only as of this - writing) lands with a cross-cutting `execution_trace` schema, a - follow-up change SHOULD converge the two rather than maintaining both - indefinitely. -- The `approval` step's suspend/resume is a third bespoke resume path - alongside `EndpointService`'s and `SynchronizationService`'s existing - ones — no generic "resume any suspended thing" mechanism exists in - `ApprovalService`, and generalizing it is out of scope here. diff --git a/openspec/changes/visual-flow-orchestration/specs/job-management/spec.md b/openspec/changes/visual-flow-orchestration/specs/job-management/spec.md deleted file mode 100644 index 371934c89..000000000 --- a/openspec/changes/visual-flow-orchestration/specs/job-management/spec.md +++ /dev/null @@ -1,67 +0,0 @@ -# job-management Specification (delta) - -## ADDED Requirements - -### Requirement: `FlowAction` runs a flow as a scheduled job (REQ-JOB-003) - -The system MUST provide `OCA\Integriq\Action\FlowAction` -implementing the same duck-typed `run(array $arguments): array` contract -as the existing `SynchronizationAction`/`PingAction` (no new Action -interface is introduced — `jobClass` resolution via the DI container is -unchanged). A `job` OR object configured with `jobClass: -'OCA\Integriq\Action\FlowAction'` and `arguments: { flowId: '' -}` MUST, when executed by `JobService::executeJob()` (on its normal cron -cadence, on-demand `run`, or forced `test`), resolve the referenced -`flow` OR object and call `FlowRunnerService::run($flow, triggerSource: -'cron')`. `FlowAction::run()` MUST return the same -`{ level, message, stackTrace, nextRun? }` shape `SynchronizationAction::run()` -already returns, deriving `level` from the resulting `flow_run.status` -(`SUCCESS` for `completed`, `WARNING` for `dead_letter`, `ERROR` for -`failed`/`stopped`-due-to-error) so `JobService::executeJob()`'s existing -`job_log` persistence (REQ-001 of this spec) requires no changes to -handle flow-backed jobs. - -@e2e exclude backend job action dispatch — covered by PHPUnit/Newman, not browser UI - -#### Scenario: a job configured with FlowAction runs the referenced flow - -- **GIVEN** an enabled `job` OR object with `jobClass: - 'OCA\Integriq\Action\FlowAction'`, `arguments: { flowId: '' - }`, and `nextRun` in the past -- **WHEN** `JobService::run()` sweeps due jobs -- **THEN** `FlowAction::run({ flowId: '' })` is invoked -- **AND** `FlowRunnerService::run()` executes the referenced flow with - `triggerSource: 'cron'` -- **AND** a `job_log` entry is written with `level` derived from the - flow run's resulting status - -#### Scenario: forced test run executes the flow regardless of schedule - -- **GIVEN** a `job` OR object configured with `FlowAction` and - `isEnabled: false` -- **WHEN** `JobsController::test(id)` is called (always `forceRun: true`, - per this spec's REQ-002) -- **THEN** `FlowAction::run()` is invoked and the flow executes anyway, - matching the existing `forceRun` semantics for every other Action type - -#### Scenario: a failing flow run is reflected as an ERROR-level job log - -- **GIVEN** a `FlowAction`-backed job whose referenced flow run ends with - `flow_run.status: failed` (an `onError: stop` step threw, per - `flow-orchestration` REQ-006) -- **WHEN** the job executes -- **THEN** `FlowAction::run()` returns `level: 'ERROR'` -- **AND** the resulting `job_log` entry records `level: ERROR` with the - flow's failure detail in `stackTrace`/`message` - -#### Notes - -- This requirement is purely additive: it registers one new concrete - `Action` class following the existing `SynchronizationAction`/ - `PingAction` pattern. No change to `JobService::executeJob()`, - `JobTask`, or the `job` schema is introduced — `jobClass` already - accepts any DI-constructible class exposing `run(array): array`. -- See `job-scheduling` REQ-003/REQ-004 for the underlying cron-dispatch - and retention mechanics `FlowAction`-backed jobs inherit unchanged - (5-minute `JobTask` cadence, per-job/global log retention, - session-scoped `userId` execution if configured on the job). diff --git a/openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md b/openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md deleted file mode 100644 index 0cd53dc1a..000000000 --- a/openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md +++ /dev/null @@ -1,72 +0,0 @@ -# rule-pipeline Specification (delta) - -## ADDED Requirements - -### Requirement: `flow` rule action type triggers a flow run (REQ-RULE-009) - -The system MUST provide a `flow` rule type in -`EndpointService::processRules()`'s type dispatch (the existing 22-way -`match` on `$ruleData['type']`, alongside `save_object`, `approval`, -etc.), valid for either `timing: before` or `timing: after`. When a -`flow` rule's conditions pass (per the existing `checkRuleConditions()` -contract, REQ-RULE-001), the system MUST resolve the rule's `configRef` -to a `flow` OR object and call `FlowRunnerService::run($flow, data: -$data)` (see `flow-orchestration` REQ-001/REQ-007). The flow runs -synchronously within the same request; its result MUST NOT alter the -pipeline's existing before/after ordering or short-circuit contract for -other rules (matching REQ-RULE-008's precedent for the `approval` type — -this requirement only adds one new dispatch entry, it does not change -REQ-RULE-001's ordering/condition/short-circuit contract). - -If the referenced flow's run ends with `flow_run.status: failed`, -`stopped`, or `dead_letter`, the rule pipeline MUST treat this the same -way it treats any other rule-level failure today (surfaced as an error -through the pipeline's existing error contract) — a flow rule does not -introduce a new pipeline-level failure mode beyond what `error`/`approval` -rule types already establish. - -@e2e exclude backend rule pipeline dispatch — covered by PHPUnit, not browser UI - -#### Scenario: a `flow` rule triggers a flow run mid-pipeline - -- **GIVEN** an endpoint with rules at order 10 (`authentication`), order - 20 (`flow`, `configRef` pointing at an enabled flow), and order 30 - (`save_object`) -- **WHEN** the pipeline evaluates the `before`-phase rules and the - order-20 rule's conditions pass -- **THEN** `FlowRunnerService::run()` is called for the referenced flow -- **AND** the order-10 and order-30 rules still run in their existing - order, unaffected by the flow rule's dispatch - -#### Scenario: a flow rule's conditions gate whether the flow runs - -- **GIVEN** a `flow` rule with a condition that evaluates false for the - current request -- **WHEN** the pipeline reaches that rule -- **THEN** `FlowRunnerService::run()` is NOT called -- **AND** the pipeline proceeds to the next rule as normal - -#### Notes - -- This requirement only adds a new entry to the existing `match` dispatch - in `processRules()` (alongside `save_object`, `authentication`, - `approval`, etc.) — the exact same integration pattern REQ-RULE-008 - already used for the `approval` type. It does not change - REQ-RULE-001's ordering/condition/short-circuit contract, which the - `flow` type reuses as-is. -- Unlike the `approval` type (REQ-RULE-008, `timing: before` only), a - `flow` rule is valid at either timing — a flow can be a pre-write - side-effect (`before`) or a post-write follow-up action (`after`), - matching how `synchronization`/`mapping` rule types are already valid - at either timing. -- A `flow` rule referencing a flow that itself contains an `approval` - step will suspend that flow run (per `flow-orchestration` REQ-005) — - from the endpoint rule pipeline's perspective this is treated - identically to any other rule dispatch that completes without altering - the pipeline's own response; the pipeline does NOT wait on or surface - the flow's suspension state synchronously. This is a deliberate v1 - simplification: chaining a suspending flow off an endpoint rule is - supported for triggering, but the endpoint response is not itself - gated on that flow's eventual approval outcome (only a direct - `approval` rule type, per REQ-RULE-008, gates the endpoint response - itself). diff --git a/openspec/changes/visual-flow-orchestration/tasks.md b/openspec/changes/visual-flow-orchestration/tasks.md index 3b01c5319..c0dab1725 100644 --- a/openspec/changes/visual-flow-orchestration/tasks.md +++ b/openspec/changes/visual-flow-orchestration/tasks.md @@ -1,215 +1,13 @@ -# Tasks: visual-flow-orchestration +# Tasks: visual-flow-orchestration (superseded) -## 1. Schema & Persistence +The original 21-task / 55-checkbox list is removed with the 2026-09-02 +re-scope (see proposal.md for the full disposition; the list survives in +`archive/2026-07-15-visual-flow-orchestration/tasks.md`, where 43/55 boxes are +checked with per-task evidence, and in git history). Work items live in the +successor changes: -### Task 1: Add `flow` OR schema register.d fragment -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-steps-execute-sequentially-in-order-req-001` -- **files**: `lib/Settings/register.d/visual-flow-orchestration.json` -- **acceptance_criteria**: - - GIVEN the fragment is merged by the AppHost repair step WHEN the register is inspected THEN a `flow` schema exists with `name`, `description`, `isEnabled`, `steps[]` (each with `order`, `type` enum, `configRef`, `condition`, `onError` enum, `branches[]`, `defaultNextStepOrder`) matching design.md Decision 1 - - GIVEN a `flow` object is saved with two steps carrying the same `order` value THEN validation rejects it (steps' `order` values MUST be unique within a flow) -- [ ] Implement -- [ ] Test +- `integriq-flow-nodes` (ready to start) +- `retire-integriq-flow-schema` (gated on integriq-flow-nodes) +- `flow-native-synchronization` (in flight, 18/19) -### Task 2: Add `flow_run` / `flow_run_log` OR schema register.d fragment -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-runs-are-persisted-with-a-per-step-trace-req-008` -- **files**: `lib/Settings/register.d/visual-flow-orchestration.json` -- **acceptance_criteria**: - - GIVEN the fragment is merged THEN `flow_run` (flowId, triggerSource, status, startedAt, finishedAt) and `flow_run_log` (flowRunId, stepOrder, type, status, startedAt, finishedAt, error) schemas exist -- [ ] Implement -- [ ] Test - -### Task 3: Extend `approval_request` schema with `flowRunId` / `resumeStepOrder` -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **files**: `lib/Settings/register.d/visual-flow-orchestration.json` (additive properties only — do NOT edit `hitl-approval-rule-action.json` directly; add a second fragment that extends the same schema, or confirm with the register merge tooling whether an additive property extension belongs in a new fragment vs. amending the existing one — follow whatever the AppHost register-merge convention already establishes for extending another change's schema) -- **acceptance_criteria**: - - GIVEN an `approval_request` object THEN it MAY carry `flowRunId` (uuid, FK to `flow_run`, `SET_NULL` on delete) and `resumeStepOrder` (integer), both optional, with no change to existing required fields or the `pending → approved|rejected|expired|dead_letter|error` state machine -- [ ] Implement -- [ ] Test - -## 2. Runner Service - -### Task 4: Implement `FlowRunnerService::run()` — sequential dispatch -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-steps-execute-sequentially-in-order-req-001` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN a 3-step flow (call → mapping → synchronization) WHEN `run($flow)` is called THEN `CallService::call()`, `MappingService::executeMapping()`, `SynchronizationService::synchronize()` are invoked in `order` sequence (not array position) and a `flow_run`/`flow_run_log` trail records all three as `completed` -- [ ] Implement -- [ ] Test - -### Task 5: Implement FlowToken context threading between steps -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-step-context-is-threaded-via-the-reused-flowtoken-req-002` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN a step's result WHEN the next step runs THEN `$flowToken->getSyncInputAmended()` returns the previous step's output, per design.md Decision 2 (no new context object introduced) - - GIVEN an endpoint-triggered flow THEN `requestOriginal`/`responseOriginal` are seeded from the triggering request at flow start -- [ ] Implement -- [ ] Test - -### Task 6: Implement `condition` evaluation (step skip) -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-step-condition-skips-a-step-when-it-evaluates-false-req-003` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN a step's `condition` evaluates false via `JWadhams\JsonLogic::apply()` WHEN the runner reaches it THEN the step's service is not called and `flow_run_log` records `status: skipped` -- [ ] Implement -- [ ] Test - -### Task 7: Implement `branch` step JsonLogic target selection -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN a `branch` step's `branches[]` WHEN evaluated in order THEN the first matching `nextStepOrder` is selected, or `defaultNextStepOrder` if none match - - GIVEN a `branch` step targets a non-existent `order` WHEN selected THEN the flow run fails fatally regardless of any step's `onError` policy -- [ ] Implement -- [ ] Test - -### Task 8: Implement `onError` policy (stop | continue | dead_letter) -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-per-step-onerror-policy-governs-failure-handling-req-006` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN a step throws WHEN `onError: stop` THEN `flow_run.status: stopped` and no later step runs - - GIVEN a step throws WHEN `onError: continue` THEN the next step still runs and the run can still reach `completed` - - GIVEN a step throws WHEN `onError: dead_letter` THEN `flow_run.status: dead_letter` (distinct from `stopped`) and no later step runs -- [ ] Implement -- [ ] Test - -### Task 9: Implement `approval` step suspend -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN an `approval` step WHEN the runner reaches it THEN an `approval_request` is created with `flowRunId` and `resumeStepOrder` set, `flow_run.status` becomes `suspended`, and `run()` returns without executing later steps -- [ ] Implement -- [ ] Test - -### Task 10: Implement `FlowRunnerService::resumeFromApproval()` -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **files**: `lib/Service/FlowRunnerService.php`, `lib/Controller/ApprovalsController.php` (add the `flowRunId` branch alongside the existing `endpointId`/`synchronizationId` branches in `approve()`/`reject()`) -- **acceptance_criteria**: - - GIVEN an approved `approval_request` with `flowRunId` set WHEN `ApprovalsController::approve()` is called THEN `FlowRunnerService::resumeFromApproval()` rehydrates the `FlowToken` via `ApprovalService::rehydrateFlowToken()` and resumes at `resumeStepOrder` - - GIVEN a rejected `approval_request` with `flowRunId` set WHEN `reject()` is called THEN `flow_run.status` becomes `stopped` -- [ ] Implement -- [ ] Test - -### Task 11: Implement `event` step dispatch to `EventService::emitCloudEvent()` -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-steps-execute-sequentially-in-order-req-001` -- **files**: `lib/Service/FlowRunnerService.php` -- **acceptance_criteria**: - - GIVEN an `event` step WHEN dispatched THEN `EventService::emitCloudEvent(type, source, subject, data)` is called with the step's config and current step context, per design.md Decision 5 -- [ ] Implement -- [ ] Test - -## 3. Triggers - -### Task 12: Add `flow` action type to `EndpointService::processRules()` -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md#requirement-flow-rule-action-type-triggers-a-flow-run-req-rule-009` -- **files**: `lib/Service/EndpointService.php` -- **acceptance_criteria**: - - GIVEN an endpoint rule of `type: flow` WHEN its conditions pass THEN `FlowRunnerService::run()` is called with the current pipeline `$data`, and existing rule ordering/short-circuit behavior (REQ-RULE-001, REQ-RULE-008) is unchanged for all other rule types -- [ ] Implement -- [ ] Test - -### Task 13: Implement `lib/Action/FlowAction.php` -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/job-management/spec.md#requirement-flowaction-runs-a-flow-as-a-scheduled-job-req-job-003` -- **files**: `lib/Action/FlowAction.php` -- **acceptance_criteria**: - - GIVEN a `job` OR object with `jobClass: 'OCA\Integriq\Action\FlowAction'` WHEN `JobService::executeJob()` invokes it THEN `FlowRunnerService::run()` executes the referenced flow and `run()` returns `{level, message, stackTrace}` derived from `flow_run.status`, matching `SynchronizationAction::run()`'s return shape -- [ ] Implement -- [ ] Test - -### Task 14: Wire event-triggered flows through EventService's subscriber path -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-a-flow-runs-via-cron-endpoint-rule-event-or-manual-trigger-req-007` -- **files**: `lib/Service/EventService.php` (or a new subscriber class alongside its existing delivery path — follow whatever pattern `EventService` already uses to match an incoming CloudEvent to a registered consumer) -- **acceptance_criteria**: - - GIVEN a CloudEvent matching a flow's configured trigger (type/source/subject) WHEN `EventService` processes it THEN `FlowRunnerService::run()` is invoked with `triggerSource: 'event'` -- [ ] Implement -- [ ] Test - -## 4. API & Controller - -### Task 15: Implement `FlowsController` (CRUD + manual run) -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-a-flow-runs-via-cron-endpoint-rule-event-or-manual-trigger-req-007` -- **files**: `lib/Controller/FlowsController.php`, `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN `POST /api/flows/{id}/run` WHEN called by an authenticated admin THEN `FlowRunnerService::run()` executes synchronously and the response carries the resulting `flow_run`'s status and `flow_run_log` - - GIVEN standard CRUD routes for `flow` objects THEN they follow the same Controller → Service → Mapper layering (ADR-008) already used by `SynchronizationsController`/`JobsController`, with explicit auth attributes per route (`hydra-gate-route-auth`) -- [ ] Implement -- [ ] Test - -## 5. Manifest & UI - -### Task 16: Add `Flows` index + `FlowDetail` custom detail manifest entries -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flows-index-and-detail-ui-provide-a-typed-step-list-editor-req-009` -- **files**: `src/manifest.json` -- **acceptance_criteria**: - - GIVEN `src/manifest.json` THEN a `Flows` `type: index` page (register `openconnector`, schema `flow`) and a `FlowDetail` `type: custom` page (`component: FlowDetailPage`, with a `_note` justifying the bespoke component per the `SynchronizationDetail` precedent) exist, plus a menu entry in the `AutomationGroup` after `SyncDeadLetters` (`order: 122`) -- [ ] Implement -- [ ] Test - -### Task 17: Build `FlowDetailPage.vue` + step-list editor -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flows-index-and-detail-ui-provide-a-typed-step-list-editor-req-009` -- **files**: `src/views/Flow/FlowDetailPage.vue`, `src/views/Flow/FlowStepRow.vue`, `src/views/Flow/FlowStepConditionEditor.vue` (reuse the existing Rules condition editor component if one exists — check `src/views/Rule/` first before writing a new one), `src/modals/Flow/EditFlow.vue` -- **acceptance_criteria**: - - GIVEN the Flow detail page THEN each step row renders a type `NcSelect` with `inputLabel: 'Step type'`, a config-ref `NcSelect` (`inputLabel` set, options filtered by the chosen step type) when applicable, a condition editor, an onError `NcSelect` (`inputLabel: 'On error'`), and move-up/move-down/remove controls — no drag-and-drop - - GIVEN a `branch` step row THEN it additionally renders a `branches[]` sub-list (condition + target-order picker) and a default-target picker - - GIVEN any modal on the Flow pages THEN it lives in `src/modals/Flow/`, not inline in `FlowDetailPage.vue` -- [ ] Implement -- [ ] Test - -### Task 18: Add client-side branch-target validation on save -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **files**: `src/views/Flow/FlowDetailPage.vue` -- **acceptance_criteria**: - - GIVEN a `branch` step's `nextStepOrder`/`defaultNextStepOrder` does not match any existing step's `order` WHEN the admin attempts to save THEN save is blocked with an inline validation error (per design.md's branch-target risk mitigation) -- [ ] Implement -- [ ] Test - -### Task 19: Add "Run" manual-trigger action + run-log view -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-a-flow-runs-via-cron-endpoint-rule-event-or-manual-trigger-req-007` -- **files**: `src/views/Flow/FlowDetailPage.vue`, `src/views/Flow/FlowRunLog.vue` -- **acceptance_criteria**: - - GIVEN the Flow detail page THEN a "Run" header action calls `POST /api/flows/{id}/run` and surfaces the resulting run's status - - GIVEN a flow with prior runs THEN a run-log list/tab shows past `flow_run`/`flow_run_log` records, following the `SyncDeadLetters`/`Job logs` list pattern -- [ ] Implement -- [ ] Test - -## 6. Integration & Cross-Cutting - -### Task 20: Verify a `synchronization` step does not bypass sync-safety guards -- **spec_ref**: `openspec/changes/visual-flow-orchestration/design.md#decision-3-dispatch--thin-adapter-methods-not-reimplementation` -- **files**: `lib/Service/FlowRunnerService.php`, `tests/Integration/FlowRunnerSynchronizationGuardsTest.php` -- **acceptance_criteria**: - - GIVEN a `synchronization` step targeting a Synchronization configured with `sourceConfig.requiresApproval` (the sync-safety batch-approval gate) WHEN the flow step runs THEN the same approval gate fires exactly as a directly-triggered sync would — the flow step MUST NOT pass any flag that bypasses it -- [ ] Implement -- [ ] Test - -### Task 21: File follow-up issues for explicitly out-of-scope v2 items -- **spec_ref**: `openspec/changes/visual-flow-orchestration/proposal.md#out-of-scope` -- **files**: N/A (GitHub issues in `ConductionNL/integriq`) -- **acceptance_criteria**: - - GIVEN this change is archived THEN three follow-up issues exist: (1) drag-and-drop canvas UI for the step editor, (2) parallel/fan-out step execution, (3) loop/iteration step types — each referencing this change's proposal.md Out of Scope section -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate visual-flow-orchestration --type change --strict` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for `FlowRunnerService` (`tests/Unit/Service/FlowRunnerServiceTest.php`): ordering, condition skip, branch selection (including unresolvable-target failure), `onError` stop/continue/dead_letter, approval suspend/resume, event dispatch. -- [ ] PHPUnit integration test: a 3-step flow (`call` → `mapping` → `synchronization`) runs end-to-end against faked/stubbed `CallService`/`MappingService`/`SynchronizationService` collaborators, asserting call order and `flow_run_log` contents (`tests/Integration/FlowRunnerIntegrationTest.php`). -- [ ] Newman/Postman tests for `POST /api/flows/{id}/run` and standard `flow` CRUD endpoints. -- [ ] Browser tests (Playwright MCP) for the Flows index page mount, step-list editor add/reorder/remove, and the manual "Run" action — trace each to REQ-009's scenarios per `hydra-gate-e2e-coverage`. -- [ ] All tests pass (`composer test`, `newman run`). - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation added in `docs/` describing the `flow` entity, step types, and the v1 scope boundary (no canvas/fan-out/loops). -- [ ] Screenshot of the Flow detail page's step-list editor captured and committed to `docs/images/`. - -## i18n (company-wide hydra ADR-007) - -- [ ] English (`en_US`) source strings added for all new Flow UI labels/messages (`l10n/` source strings are authored in English per project convention). -- [ ] Dutch (`nl_NL`) translations added for the same strings. +There is nothing to implement from this change directly. diff --git a/openspec/changes/visual-flow-orchestration/test-plan.md b/openspec/changes/visual-flow-orchestration/test-plan.md deleted file mode 100644 index e9dd8ecbd..000000000 --- a/openspec/changes/visual-flow-orchestration/test-plan.md +++ /dev/null @@ -1,234 +0,0 @@ -# Test Plan: visual-flow-orchestration - -## Test Cases - -### TC-1: 3-step flow runs in order (call → mapping → synchronization) -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-steps-execute-sequentially-in-order-req-001` -- **type**: functional (unit + integration, PHPUnit) -- **persona**: N/A (backend) -- **preconditions**: a `flow` with steps order 10 (`call`), order 20 (`mapping`), order 30 (`synchronization`), all `onError: stop` -- **steps**: call `FlowRunnerService::run($flow)` -- **expected result**: `CallService::call()`, `MappingService::executeMapping()`, `SynchronizationService::synchronize()` invoked in that order; `flow_run_log` has 3 `completed` entries in order 10/20/30 -- **test command**: PHPUnit (`tests/Unit/Service/FlowRunnerServiceTest.php`, `tests/Integration/FlowRunnerIntegrationTest.php`) - -### TC-2: steps execute by `order` field, not array position -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-steps-execute-sequentially-in-order-req-001` -- **type**: functional -- **preconditions**: a flow whose `steps[]` array lists order 30 before order 10 -- **steps**: call `run($flow)` -- **expected result**: execution order is still 10 then 30 -- **test command**: PHPUnit - -### TC-3: step output threads into next step's input via FlowToken -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-step-context-is-threaded-via-the-reused-flowtoken-req-002` -- **type**: functional -- **preconditions**: order-10 `mapping` step returns `{"id":"abc"}` -- **steps**: runner proceeds to order-20 `synchronization` step -- **expected result**: `SynchronizationService::synchronize()` called with `data: {"id":"abc"}` -- **test command**: PHPUnit - -### TC-4: false step condition skips the step -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-step-condition-skips-a-step-when-it-evaluates-false-req-003` -- **type**: functional -- **preconditions**: order-20 step's `condition` evaluates false against current context -- **steps**: runner reaches order-20 step -- **expected result**: step's service NOT called; `flow_run_log` records `status: skipped`; execution continues to next step -- **test command**: PHPUnit - -### TC-5: true step condition runs the step normally -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-step-condition-skips-a-step-when-it-evaluates-false-req-003` -- **type**: functional -- **preconditions**: same step, condition now evaluates true -- **steps**: runner reaches the step -- **expected result**: service is called; `flow_run_log` records `completed` (or `failed`) -- **test command**: PHPUnit - -### TC-6: branch step selects the first matching target -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **type**: functional -- **preconditions**: `branch` step at order 20 with two `branches[]` entries; context matches the second -- **steps**: runner reaches order-20 branch step -- **expected result**: execution jumps to the matched `nextStepOrder`; the skipped intermediate step does not appear in `flow_run_log` -- **test command**: PHPUnit - -### TC-7: branch falls back to defaultNextStepOrder when no branch matches -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **type**: functional -- **preconditions**: no `branches[].condition` matches; `defaultNextStepOrder` set -- **steps**: runner reaches the branch step -- **expected result**: execution proceeds to `defaultNextStepOrder` -- **test command**: PHPUnit - -### TC-8: unresolvable branch target fails the run fatally -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **type**: functional -- **preconditions**: matched `nextStepOrder` does not correspond to any existing step -- **steps**: runner selects that branch -- **expected result**: flow run fails with `flow_run.status: failed`, regardless of any step's `onError` policy -- **test command**: PHPUnit - -### TC-9: approval step suspends the run -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **type**: functional -- **preconditions**: flow with an `approval` step at order 20 -- **steps**: runner reaches order-20 step -- **expected result**: `approval_request` created with `flowRunId`/`resumeStepOrder: 30`; `run()` returns with `flow_run.status: suspended`; no later step executes -- **test command**: PHPUnit - -### TC-10: approving resumes the flow from the next step -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **type**: api -- **preconditions**: the suspended run from TC-9 -- **steps**: authorized approver calls `POST /api/approvals/{id}/approve` -- **expected result**: `FlowRunnerService::resumeFromApproval()` invoked; execution resumes at order 30; run reaches `completed` -- **test command**: `/test-api` (Newman) + PHPUnit - -### TC-11: rejecting stops the flow -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-approval-step-suspends-and-resumes-the-flow-run-req-005` -- **type**: api -- **preconditions**: the suspended run -- **steps**: authorized approver calls `POST /api/approvals/{id}/reject` with a mandatory comment -- **expected result**: no further steps run; `flow_run.status: stopped` -- **test command**: `/test-api` (Newman) + PHPUnit - -### TC-12: onError stop halts the run on the failing step -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-per-step-onerror-policy-governs-failure-handling-req-006` -- **type**: functional -- **preconditions**: order-20 step has `onError: stop`, throws -- **steps**: runner processes the step -- **expected result**: `flow_run_log` records `failed`; `flow_run.status: stopped`; no later step runs -- **test command**: PHPUnit - -### TC-13: onError continue proceeds past the failing step -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-per-step-onerror-policy-governs-failure-handling-req-006` -- **type**: functional -- **preconditions**: order-20 step has `onError: continue`, throws -- **steps**: runner processes the step -- **expected result**: order-30 step still executes; run can reach `completed` -- **test command**: PHPUnit - -### TC-14: onError dead_letter marks the run distinctly from stop -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-per-step-onerror-policy-governs-failure-handling-req-006` -- **type**: functional -- **preconditions**: order-20 step has `onError: dead_letter`, throws -- **steps**: runner processes the step -- **expected result**: `flow_run.status: dead_letter` (not `stopped`); no later step runs -- **test command**: PHPUnit - -### TC-15: a cron job triggers a flow via FlowAction -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/job-management/spec.md#requirement-flowaction-runs-a-flow-as-a-scheduled-job-req-job-003` -- **type**: functional -- **preconditions**: enabled `job` with `jobClass: FlowAction`, `arguments: {flowId}`, due `nextRun` -- **steps**: `JobService::run()` sweeps due jobs -- **expected result**: `FlowAction::run()` invoked; flow executes with `triggerSource: 'cron'`; `job_log` written -- **test command**: PHPUnit - -### TC-16: an endpoint rule triggers a flow -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/rule-pipeline/spec.md#requirement-flow-rule-action-type-triggers-a-flow-run-req-rule-009` -- **type**: api -- **preconditions**: endpoint with a `flow`-type rule, `configRef` pointing at an enabled flow, conditions pass -- **steps**: call the endpoint -- **expected result**: `FlowRunnerService::run()` invoked with pipeline data as initial input; other rules run in existing order -- **test command**: `/test-api` (Newman) - -### TC-17: manual run triggers a flow synchronously -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-a-flow-runs-via-cron-endpoint-rule-event-or-manual-trigger-req-007` -- **type**: functional -- **persona**: Priya (ZZP developer / integrator) — configures and manually tests a flow -- **preconditions**: admin viewing the Flow detail page for an enabled flow -- **steps**: click "Run" -- **expected result**: `POST /api/flows/{id}/run` called; response carries the resulting `flow_run`'s status and log -- **test command**: `/test-functional` - -### TC-18: completed run's log reflects every step outcome -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flow-runs-are-persisted-with-a-per-step-trace-req-008` -- **type**: functional -- **preconditions**: 3-step flow where the middle step's condition is false -- **steps**: run the flow -- **expected result**: `flow_run_log` has 3 entries (`completed`, `skipped`, `completed`); `flow_run.status: completed`, `finishedAt` set -- **test command**: PHPUnit - -### TC-19: Flows index page mounts and lists flows -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flows-index-and-detail-ui-provide-a-typed-step-list-editor-req-009` -- **type**: functional -- **persona**: Noor (Municipal CISO / Functional Admin) — reviews configured automation -- **preconditions**: authenticated admin -- **steps**: navigate to `/apps/integriq/flows` via sidebar nav -- **expected result**: Flows index page renders with content; lists name, enabled state, last-run status -- **test command**: `/test-functional` - -### TC-20: step-list editor's config-ref picker is scoped to the selected type -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flows-index-and-detail-ui-provide-a-typed-step-list-editor-req-009` -- **type**: functional -- **persona**: Priya (ZZP developer / integrator) -- **preconditions**: admin on the Flow detail page -- **steps**: click "Add step", select `type: mapping`, open the config-ref picker -- **expected result**: picker options are Mappings only (not Sources/Synchronizations/Endpoints) -- **test command**: `/test-functional` - -### TC-21: reordering uses move controls, not drag-and-drop -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-flows-index-and-detail-ui-provide-a-typed-step-list-editor-req-009` -- **type**: functional -- **preconditions**: flow with 3 steps -- **steps**: click "Move up" on the second step -- **expected result**: second and first step's `order` values swap; no drag-and-drop present -- **test command**: `/test-functional` - -### TC-22: NcSelect controls carry inputLabel (WCAG 1.3.1 / 4.1.2) -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#non-functional-requirements` -- **type**: accessibility -- **preconditions**: Flow detail page's step-list editor rendered -- **steps**: audit every `NcSelect` on the page -- **expected result**: every select carries a non-empty `inputLabel` (or `ariaLabelCombobox`); zero `hydra-gate-nc-input-labels` violations -- **test command**: `/test-accessibility` - -### TC-23: branch-target validation blocks save on a dangling reference -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/flow-orchestration/spec.md#requirement-branch-step-selects-the-next-step-via-jsonlogic-req-004` -- **type**: functional -- **preconditions**: a `branch` step's `nextStepOrder` does not match any step's `order` -- **steps**: admin attempts to save the flow -- **expected result**: save is blocked with an inline validation error -- **test command**: `/test-functional` - -### TC-24: synchronization step does not bypass sync-safety approval gate -- **spec_ref**: `openspec/changes/visual-flow-orchestration/design.md#decision-3-dispatch--thin-adapter-methods-not-reimplementation` -- **type**: regression -- **preconditions**: a `synchronization` step targets a Synchronization with `sourceConfig.requiresApproval` set -- **steps**: run the flow step -- **expected result**: the same batch-approval gate fires as a directly-triggered sync; no bypass flag is set by the flow step -- **test command**: PHPUnit (regression against `synchronization-engine` REQ-015 behaviour) - -### TC-25: FlowAction's returned level maps job_log correctly across outcomes -- **spec_ref**: `openspec/changes/visual-flow-orchestration/specs/job-management/spec.md#requirement-flowaction-runs-a-flow-as-a-scheduled-job-req-job-003` -- **type**: functional -- **preconditions**: three job-backed flow runs ending `completed`, `dead_letter`, `failed` respectively -- **steps**: execute each job -- **expected result**: `job_log.level` is `SUCCESS`, `WARNING`, `ERROR` respectively -- **test command**: PHPUnit - -## Coverage Summary - -| Requirement | Covered by | Status | -|---|---|---| -| flow-orchestration REQ-001 (sequential execution) | TC-1, TC-2 | Covered | -| flow-orchestration REQ-002 (context threading) | TC-3 | Covered | -| flow-orchestration REQ-003 (condition skip) | TC-4, TC-5 | Covered | -| flow-orchestration REQ-004 (branch) | TC-6, TC-7, TC-8, TC-23 | Covered | -| flow-orchestration REQ-005 (approval suspend/resume) | TC-9, TC-10, TC-11 | Covered | -| flow-orchestration REQ-006 (onError) | TC-12, TC-13, TC-14 | Covered | -| flow-orchestration REQ-007 (triggers) | TC-15, TC-16, TC-17 | Covered | -| flow-orchestration REQ-008 (trace/log) | TC-18 | Covered | -| flow-orchestration REQ-009 (UI) | TC-19, TC-20, TC-21, TC-22 | Covered | -| rule-pipeline REQ-RULE-009 (flow rule action) | TC-16 | Covered | -| job-management REQ-JOB-003 (FlowAction) | TC-15, TC-25 | Covered | -| sync-safety composition (design.md Decision 3) | TC-24 | Covered | - -## Out of Scope - -- No test cases for a drag-and-drop canvas, parallel/fan-out execution, or - loop/iteration steps — these are explicitly out of scope for v1 per - proposal.md; test coverage is deferred to whichever future change - implements them. -- No load/performance test case for flow execution latency under - concurrent triggers — the NFR documents sequential-latency as a known - v1 ceiling, not a target to benchmark against in this change. From ca4af80b8e38ade51295f7ae83c31f697ce41aa9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 16:01:56 +0200 Subject: [PATCH 19/22] docs(openspec): retire twelve resurrected duplicates of archived changes (#1820) Each of these live change dirs was a 0-ticked resurrection of work already implemented and archived in the 2026-07-14..16 window (verified per dir against code at HEAD). Rewritten as superseded redirects with disposition tables per the PR #1815 pattern: retired in place, no renames, every live @spec anchor file and heading kept. --- .../changes/api-product-gateway/design.md | 336 --------------- .../changes/api-product-gateway/discovery.md | 143 ------- .../changes/api-product-gateway/migration.md | 116 ------ .../changes/api-product-gateway/proposal.md | 209 ++-------- .../specs/api-product-gateway/spec.md | 250 ----------- .../specs/consumer-management/spec.md | 52 --- .../specs/endpoint-runtime/spec.md | 78 ---- .../specs/prometheus-metrics/spec.md | 70 ---- openspec/changes/api-product-gateway/tasks.md | 136 +----- .../changes/api-product-gateway/test-plan.md | 237 ----------- .../changes/cdc-incremental-sync/design.md | 276 ------------- .../changes/cdc-incremental-sync/proposal.md | 198 ++------- .../specs/synchronization-engine/spec.md | 391 ------------------ .../changes/cdc-incremental-sync/tasks.md | 160 +------ .../changes/cdc-incremental-sync/test-plan.md | 224 ---------- .../connector-catalog-ui/context-brief.md | 24 -- .../changes/connector-catalog-ui/discovery.md | 39 -- .../changes/connector-catalog-ui/migration.md | 51 --- .../changes/connector-catalog-ui/proposal.md | 117 +++--- .../specs/configuration-export-import/spec.md | 62 --- .../specs/connector-catalog/spec.md | 99 ----- .../specs/openconnector-app-manifest/spec.md | 27 -- .../changes/connector-catalog-ui/tasks.md | 174 +------- .../changes/connector-catalog-ui/test-plan.md | 180 -------- .../environments-and-promotion/design.md | 349 ---------------- .../environments-and-promotion/discovery.md | 103 ----- .../environments-and-promotion/migration.md | 99 ----- .../environments-and-promotion/proposal.md | 194 ++------- .../specs/configuration-export-import/spec.md | 51 --- .../specs/environments-and-promotion/spec.md | 231 ----------- .../environments-and-promotion/tasks.md | 144 +------ .../environments-and-promotion/test-plan.md | 163 -------- .../execution-trace-observability/proposal.md | 109 +++-- .../specs/execution-trace/spec.md | 366 ---------------- .../specs/http-call-engine/spec.md | 63 --- .../specs/rule-pipeline/spec.md | 100 ----- .../execution-trace-observability/tasks.md | 185 +-------- .../test-plan.md | 197 --------- .../flow-workflowengine-integration/design.md | 257 ------------ .../discovery.md | 114 ----- .../proposal.md | 143 ++----- .../flow-workflowengine-operations/spec.md | 237 ----------- .../flow-workflowengine-integration/tasks.md | 129 +----- .../test-plan.md | 137 ------ .../nextcloud-forms-connector/design.md | 353 ---------------- .../nextcloud-forms-connector/discovery.md | 136 ------ .../nextcloud-forms-connector/proposal.md | 213 +++------- .../specs/events-cloudevents/spec.md | 62 --- .../specs/synchronization-engine/spec.md | 57 --- .../nextcloud-forms-connector/tasks.md | 128 +----- .../nextcloud-forms-connector/test-plan.md | 208 ---------- .../notificaties-api-subscriber/design.md | 236 ----------- .../notificaties-api-subscriber/migration.md | 71 ---- .../notificaties-api-subscriber/proposal.md | 166 ++------ .../specs/consumer-management/spec.md | 40 -- .../specs/events-cloudevents/spec.md | 92 ----- .../specs/notificaties-api-connector/spec.md | 266 ------------ .../notificaties-api-subscriber/tasks.md | 172 +------- .../notificaties-api-subscriber/test-plan.md | 198 --------- .../context-brief.md | 26 -- .../design.md | 172 -------- .../proposal.md | 107 ++--- .../specs/dead-letter-replay/spec.md | 146 ------- .../specs/http-call-engine/spec.md | 166 -------- .../specs/job-scheduling/spec.md | 189 --------- .../specs/prometheus-metrics/spec.md | 40 -- .../specs/synchronization-engine/spec.md | 65 --- .../tasks.md | 161 +------- .../test-plan.md | 197 --------- .../changes/secret-hygiene/context-brief.md | 22 - openspec/changes/secret-hygiene/design.md | 220 ---------- openspec/changes/secret-hygiene/proposal.md | 195 ++------- .../specs/authentication-twig/spec.md | 94 ----- .../specs/configuration-export-import/spec.md | 103 ----- .../specs/http-call-engine/spec.md | 158 ------- openspec/changes/secret-hygiene/tasks.md | 136 +----- openspec/changes/secret-hygiene/test-plan.md | 171 -------- .../sync-safety-guardrails/context-brief.md | 36 -- .../changes/sync-safety-guardrails/design.md | 175 -------- .../sync-safety-guardrails/proposal.md | 94 ++--- .../specs/synchronization-engine/spec.md | 289 ------------- .../changes/sync-safety-guardrails/tasks.md | 196 +-------- .../sync-safety-guardrails/test-plan.md | 175 -------- .../changes/tables-bridge/context-brief.md | 25 -- openspec/changes/tables-bridge/contract.md | 115 ------ openspec/changes/tables-bridge/design.md | 324 --------------- openspec/changes/tables-bridge/discovery.md | 146 ------- openspec/changes/tables-bridge/migration.md | 61 --- openspec/changes/tables-bridge/proposal.md | 239 +++-------- .../specs/synchronization-engine/spec.md | 68 --- openspec/changes/tables-bridge/tasks.md | 140 +------ openspec/changes/tables-bridge/test-plan.md | 187 --------- 92 files changed, 602 insertions(+), 13454 deletions(-) delete mode 100644 openspec/changes/api-product-gateway/design.md delete mode 100644 openspec/changes/api-product-gateway/discovery.md delete mode 100644 openspec/changes/api-product-gateway/migration.md delete mode 100644 openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md delete mode 100644 openspec/changes/api-product-gateway/specs/consumer-management/spec.md delete mode 100644 openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md delete mode 100644 openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md delete mode 100644 openspec/changes/api-product-gateway/test-plan.md delete mode 100644 openspec/changes/cdc-incremental-sync/design.md delete mode 100644 openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md delete mode 100644 openspec/changes/cdc-incremental-sync/test-plan.md delete mode 100644 openspec/changes/connector-catalog-ui/context-brief.md delete mode 100644 openspec/changes/connector-catalog-ui/discovery.md delete mode 100644 openspec/changes/connector-catalog-ui/migration.md delete mode 100644 openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md delete mode 100644 openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md delete mode 100644 openspec/changes/connector-catalog-ui/specs/openconnector-app-manifest/spec.md delete mode 100644 openspec/changes/connector-catalog-ui/test-plan.md delete mode 100644 openspec/changes/environments-and-promotion/design.md delete mode 100644 openspec/changes/environments-and-promotion/discovery.md delete mode 100644 openspec/changes/environments-and-promotion/migration.md delete mode 100644 openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md delete mode 100644 openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md delete mode 100644 openspec/changes/environments-and-promotion/test-plan.md delete mode 100644 openspec/changes/execution-trace-observability/specs/execution-trace/spec.md delete mode 100644 openspec/changes/execution-trace-observability/specs/http-call-engine/spec.md delete mode 100644 openspec/changes/execution-trace-observability/specs/rule-pipeline/spec.md delete mode 100644 openspec/changes/execution-trace-observability/test-plan.md delete mode 100644 openspec/changes/flow-workflowengine-integration/design.md delete mode 100644 openspec/changes/flow-workflowengine-integration/discovery.md delete mode 100644 openspec/changes/flow-workflowengine-integration/specs/flow-workflowengine-operations/spec.md delete mode 100644 openspec/changes/flow-workflowengine-integration/test-plan.md delete mode 100644 openspec/changes/nextcloud-forms-connector/design.md delete mode 100644 openspec/changes/nextcloud-forms-connector/discovery.md delete mode 100644 openspec/changes/nextcloud-forms-connector/specs/events-cloudevents/spec.md delete mode 100644 openspec/changes/nextcloud-forms-connector/specs/synchronization-engine/spec.md delete mode 100644 openspec/changes/nextcloud-forms-connector/test-plan.md delete mode 100644 openspec/changes/notificaties-api-subscriber/design.md delete mode 100644 openspec/changes/notificaties-api-subscriber/migration.md delete mode 100644 openspec/changes/notificaties-api-subscriber/specs/consumer-management/spec.md delete mode 100644 openspec/changes/notificaties-api-subscriber/specs/events-cloudevents/spec.md delete mode 100644 openspec/changes/notificaties-api-subscriber/specs/notificaties-api-connector/spec.md delete mode 100644 openspec/changes/notificaties-api-subscriber/test-plan.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/context-brief.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/design.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/specs/dead-letter-replay/spec.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/specs/http-call-engine/spec.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/specs/job-scheduling/spec.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/specs/prometheus-metrics/spec.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/specs/synchronization-engine/spec.md delete mode 100644 openspec/changes/retry-and-circuit-breaker-policies/test-plan.md delete mode 100644 openspec/changes/secret-hygiene/context-brief.md delete mode 100644 openspec/changes/secret-hygiene/design.md delete mode 100644 openspec/changes/secret-hygiene/specs/authentication-twig/spec.md delete mode 100644 openspec/changes/secret-hygiene/specs/configuration-export-import/spec.md delete mode 100644 openspec/changes/secret-hygiene/specs/http-call-engine/spec.md delete mode 100644 openspec/changes/secret-hygiene/test-plan.md delete mode 100644 openspec/changes/sync-safety-guardrails/context-brief.md delete mode 100644 openspec/changes/sync-safety-guardrails/design.md delete mode 100644 openspec/changes/sync-safety-guardrails/specs/synchronization-engine/spec.md delete mode 100644 openspec/changes/sync-safety-guardrails/test-plan.md delete mode 100644 openspec/changes/tables-bridge/context-brief.md delete mode 100644 openspec/changes/tables-bridge/contract.md delete mode 100644 openspec/changes/tables-bridge/design.md delete mode 100644 openspec/changes/tables-bridge/discovery.md delete mode 100644 openspec/changes/tables-bridge/migration.md delete mode 100644 openspec/changes/tables-bridge/specs/synchronization-engine/spec.md delete mode 100644 openspec/changes/tables-bridge/test-plan.md diff --git a/openspec/changes/api-product-gateway/design.md b/openspec/changes/api-product-gateway/design.md deleted file mode 100644 index 822de9e17..000000000 --- a/openspec/changes/api-product-gateway/design.md +++ /dev/null @@ -1,336 +0,0 @@ -# Design: api-product-gateway - -## Architecture Overview - -``` - ┌────────────────────┐ - Consumer subscribes │ api_product │ groups N Endpoints (uuid[]) - ───────────────────► │ - tiers{name:policy}│ version + status + sunsetDate - │ - defaultTier │ visibility - └─────────┬───────────┘ - │ 1:N - ┌─────────▼───────────┐ requiresApproval? ──► ApprovalService - │ api_product_ │ (approval_request, generic) - │ subscription │ - │ - consumer, tier │ - │ - status │ - └─────────┬───────────┘ - │ resolved at request time - inbound request ──► EndpointService::doHandleRequest() - │ - ├─ resolveProductTierPolicy() ──► InboundRateLimitService::enforce() - │ (NEW — reads api_product+subscription; falls back to (UNCHANGED) - │ Consumer.rateLimit/quota when no product/tier applies) - │ - ├─ recordInboundCallLog() ──► call_log (product, endpoint, responseTime) - │ (NEW — only for product-attached endpoints) - │ - └─ handleRequest() header-merge loop - ├─ RateLimit-* / Retry-After (UNCHANGED, REQ-CON-RL-003) - └─ Sunset / Deprecation (NEW, RFC 8594) - - GET /api/metrics ──► MetricsController - ├─ declarative groupBy(call_log, product) → request/error counts - └─ IntegriqMetricsProvider (escape hatch) → p50/p95/p99 gauges -``` - -## API Design - -`api_product` and `api_product_subscription` are plain OpenRegister-backed -schemas; CRUD goes through OpenRegister's generic object API -(`/api/objects/integriq/api_product`, `/api/objects/integriq/ -api_product_subscription`) exactly like `endpoint` and `consumer` today — no -bespoke controller (`openconnector-direct-or-usage` / redundant-controller -avoidance). Two small custom endpoints are needed for the approval-gated -subscribe flow, mirroring `ApprovalsController`'s shape: - -### `POST /api/products/{productId}/subscriptions` - -**Request:** -```json -{ "consumerId": "", "tier": "gold" } -``` -**Response (201, auto-approved tier):** -```json -{ "uuid": "", "status": "active", "tier": "gold", "product": "", "consumer": "" } -``` -**Response (202, approval-required tier):** -```json -{ "uuid": "", "status": "pending_approval", "approvalRequestId": "" } -``` - -### `GET /api/products/{productId}/analytics` - -**Request:** (query params `window` seconds, default 3600) - -**Response (200):** -```json -{ - "requestCount": 4213, - "errorRate": 0.012, - "latency": { "p50": 42, "p95": 180, "p99": 410 } -} -``` - -## Database Changes - -Shipped as one `register.d` fragment (ADR-037): -`lib/Settings/register.d/api-product-gateway.json`. - -- **New schema `api_product`** — `uuid`, `name` (required), `description`, - `productSlug` (required — groups version-rows of the same logical - product), `version` (semver, default `1.0.0`), `visibility` - (`public`|`private`, default `public`), `status` - (`active`|`deprecated`, default `active`), `sunsetDate` (date-time, - required when `status: deprecated`), `endpoints` (array of Endpoint uuid - strings — same array-of-string-ref pattern as `endpoint.rules`), `tiers` - (object map `tierName -> {rateLimit, quota, requiresApproval}`, same - `rateLimit`/`quota` shape as the `consumer` schema), `defaultTier` - (string), `created`/`updated`. -- **New schema `api_product_subscription`** — `uuid`, `product` (uuid FK → - `api_product`, onDelete CASCADE), `consumer` (uuid FK → `consumer`, - onDelete CASCADE), `tier` (string), `status` - (`pending_approval`|`active`|`rejected`|`revoked`, default - `pending_approval`), `approvalRequestId` (uuid FK → `approval_request`, - onDelete SET_NULL, nullable), `requesterUserId`, `createdAt`, - `activatedAt`, `revokedAt`. -- **Deep-merge onto existing `call_log`** (per `99-source-secrets-writeonly.json` - precedent — a fragment can add properties to a pre-existing schema without - touching the monolith): `product` (uuid FK → `api_product`, onDelete - SET_NULL), `endpoint` (uuid FK → `endpoint`, onDelete SET_NULL), - `responseTime` (integer, milliseconds — top-level so it is directly - aggregatable for percentile queries, unlike the outbound path's nested - `response.responseTime`). - -Full migration plan: see `migration.md`. - -## Decisions - -### Decision 1: `api_product` rows are per-(product, version), not nested version arrays - -**Choice:** One `api_product` OR object per product **version**, grouped by -a shared `productSlug`. Deprecating "v1" means setting `status: deprecated` -+ `sunsetDate` on the v1 row; v2 is untouched. - -**Alternative considered:** A single `api_product` row per product name with -a nested `versions[]` array (each carrying its own `endpoints`/`status`/ -`sunsetDate`). Rejected: every existing versioned entity in this schema -(`endpoint.version`, `source` has no version) uses a flat row + slug/ -reference-grouping pattern, not nested version arrays; nesting would be the -first of its kind in this register and complicates the tier/rate-limit -resolution query (`resolveProductTierPolicy()` would need to reach *into* a -JSON array instead of a direct object lookup by uuid). - -### Decision 2: A subscription in `pending_approval` blocks access (403), it does not fall back to `defaultTier` - -**Choice:** A request from a Consumer whose only subscription to the -product is `pending_approval` (or has none at all) is rejected with 403 — -"subscribe" is opt-in access, and falling back to `defaultTier` would let an -unapproved consumer bypass the approval gate the operator explicitly -configured for that tier. - -**Alternative considered:** Silently applying `defaultTier`'s policy while -approval is pending, so the consumer isn't blocked. Rejected: this defeats -the entire purpose of `requiresApproval` — an operator who gates a tier -behind approval expects zero access until approved, not degraded-but-open -access. - -### Decision 3: Percentiles are computed from a bounded per-product `call_log` window at scrape time, not pre-aggregated storage - -**Choice:** `IntegriqMetricsProvider::metrics()` gains one new sample -producer that, per `api_product`, fetches the most recent N (bound: 1000, -matching the existing `REQ-PROM-007` top-100-cardinality-class precedent -scaled for a per-row not per-series cap) inbound `call_log` rows with that -`product` uuid within the query window, sorts their `responseTime` values, -and computes p50/p95/p99 by index. No new aggregate table, no background -job — consistent with every other `REQ-PROM-*` metric ("computed at query -time from existing tables"). - -**Alternative considered:** A dedicated `api_product_latency_bucket` -pre-aggregation table updated on every request (true HDR-histogram style). -Rejected: violates the "no new data model entities" precedent every other -metric in this app follows, and adds a write on the hot request path for a -metric that only needs to be right at scrape granularity (15s per -`REQ-PROM-001`), not per-request. - -### Decision 4: Subscription approval reuses `ApprovalService`'s generic state machine via one new creation method, not `suspend()` - -**Choice:** Add `ApprovalService::suspendForSubscription(string -$subscriptionId, string $approverGroup, string $onReject, int $ttlSeconds): -ObjectEntity`, structurally identical to the existing -`suspendForSynchronization()` (empty `snapshot`, no FlowToken, persists -`pending` `approval_request`, calls the existing `notifyApprovers()`). -`SubscriptionsController::approve()`/`reject()` call the existing, already -subject-agnostic `ApprovalService::completeApproval()` / -`ApprovalService::reject()` directly — no new approve/reject logic. On -`completeApproval()`, the controller (not `ApprovalService`, keeping the -orchestration split `ApprovalService`'s own docblock already documents) -flips the `api_product_subscription.status` to `active` and stamps -`activatedAt`. - -**Alternative considered:** Generalizing `ApprovalService::suspend()` itself -to accept an arbitrary "subject" instead of `(endpoint, rule, flowToken)`. -Rejected: `suspend()`'s snapshot-stripping and `resumeOrder` fields are -meaningless for a subscription and would become dead parameters on this call -path — `suspendForSynchronization()` already proved the "small dedicated -creation method, shared everything else" pattern is the lower-risk fork -point. - -### Decision 5: Per-tier rate-limit resolution is a new step ahead of `enforceInboundRateLimit()`, `InboundRateLimitService::enforce()` is untouched - -**Choice:** `EndpointService` gains a private -`resolveProductTierPolicy(ObjectEntity $endpoint, ObjectEntity $consumer): -?array` returning `['key' => string, 'rateLimit' => ?array, 'quota' => -?array]` or `null`. When non-null, `enforceInboundRateLimit()` uses its -`key`/`rateLimit`/`quota` instead of deriving them from the Consumer -directly; when `null` (endpoint isn't in any `api_product`, or the consumer -has no `active` subscription to that product), behaviour is byte-for-byte -today's `REQ-CON-RL-002` path. The resolved `key` is namespaced -`product:{productUuid}:consumer:{consumerKey}` so it never collides with a -plain `consumer:{key}`/`ip:{addr}` counter in the same distributed cache. - -**Alternative considered:** A new `ProductRateLimitService` wrapping -`InboundRateLimitService`. Rejected: `enforce()`'s contract (`consumerKey`, -`rateLimit`, `quota` → `RateLimitDecision`) is already exactly what's -needed; a wrapper service would just forward three resolved values to the -same method, adding a layer with no behaviour of its own — the resolution -logic belongs next to where the Consumer's own `rateLimit`/`quota` are -already read (`enforceInboundRateLimit()`), not behind a new service -boundary. - -### Decision 6: Deprecation headers reuse the existing `handleRequest()` header-merge choke point - -**Choice:** `$this->deprecationHeaders` (new instance array, same lifecycle -as the existing `$this->rateLimitHeaders`) is populated when the matched -endpoint belongs to an `api_product` with `status: deprecated`: -`Deprecation: true` and `Sunset: ` (RFC 8594). -`handleRequest()`'s existing header-merge `foreach` loop (today only over -`rateLimitHeaders`) iterates both bags. - -**Alternative considered:** A new `after`-timing rule type -(`deprecation_headers`), dispatched from `processRules()` like -`selfurl_hal` (`REQ-EP-006`). Rejected: `selfurl_hal` needs to be opt-in -per-Endpoint because it's a general-purpose output helper; deprecation -headers are not opt-in — they are a direct, non-optional consequence of the -product's own `status` field, so gating them behind a Rule an operator must -remember to attach on every endpoint is the wrong default and an easy way to -silently under-deliver RFC 8594 compliance. - -## Risks / Trade-offs - -- [Risk] Product-scoped inbound `call_log` writes add one extra OR - `saveObject()` call per request on product-attached endpoints → [Mitigation] - scoped only to product-attached endpoints (see proposal.md Risk 1); the - write is best-effort (same try/catch-and-log pattern as - `recordInboundThrottle()` — a logging failure never blocks the response). -- [Risk] A consumer with an `active` subscription whose product is later - deleted leaves `api_product_subscription.product` null (`SET_NULL`) → - [Mitigation] `resolveProductTierPolicy()` treats a subscription with a - null `product` as "no policy" (falls back to Consumer-level), so the - subscription row becomes an inert audit record rather than a crash. -- [Risk] Percentile computation reads up to 1000 rows per product per scrape - → [Mitigation] see Decision 3; a query failure falls back to a zero-value - sample with a warning logged (matches `REQ-PROM-011`'s existing degraded - pattern), never a 500. - -## Migration Plan - -See `migration.md`. - -## Nextcloud Integration - -- Controllers: `lib/Controller/ProductSubscriptionsController.php` (new, - thin — subscribe/approve/reject/analytics; everything else is generic OR - object CRUD). -- Services: `lib/Service/EndpointService.php` (extended), - `lib/Service/ApprovalService.php` (extended, one new method). -- Mappers/Entities: none new — everything is an OpenRegister object, no - app-local `Db\` entity/mapper per `openconnector-direct-or-usage`. -- Observability: `lib/Observability/IntegriqMetricsProvider.php` - (extended), `src/manifest.json` `observability.metrics[]` (extended - `calls_total` groupBy + one new declarative descriptor). - -## Security Considerations - -- Subscription creation/approve/reject follow the same two-layer - authorization `ApprovalService` already enforces - (`isAuthorizedApprover()` — NC admin or `approverGroup` member); no new - authorization primitive is introduced. -- The `POST /api/products/{id}/subscriptions` endpoint requires an - authenticated NC admin session (creating subscriptions on behalf of a - Consumer is an administrative action, same posture as Consumer - create/edit today) — `#[NoAdminRequired]` is deliberately NOT used here, - matching `ConsumersController`'s existing posture. -- Per-tier rate-limit counters use the same hashed, TTL-bound distributed - cache keys as the existing consumer-level limiter — no new attack surface - (no new secret, no new plaintext credential field). -- Sunset/Deprecation headers disclose only information the operator already - configured (a product's own deprecation status) — no information - disclosure risk. - -## File Structure - -``` -lib/ - Controller/ - ProductSubscriptionsController.php (new) - Service/ - EndpointService.php (modified — tier resolution, deprecation headers, inbound logging) - ApprovalService.php (modified — suspendForSubscription()) - Observability/ - IntegriqMetricsProvider.php (modified — percentile gauges) - Settings/ - register.d/ - api-product-gateway.json (new) -src/ - manifest.json (modified — pages, menu, observability.metrics) - views/ - ApiProducts/ - ApiProductsIndex.vue (new, if custom list chrome is needed beyond generic index) - ApiProductDetail.vue (new — endpoint picker, tier editor, analytics panel, subscriptions) -tests/ - Unit/Service/EndpointServiceTierPolicyTest.php (new) - Unit/Observability/IntegriqMetricsProviderTest.php (extended) - postman/ (Newman collection additions — over-tier 429, deprecated headers) -``` - -## Seed Data - -### Schema: `api_product` - -| Field | Object 1 | Object 2 | Object 3 | -|-------|----------|----------|----------| -| slug | `api-product-woo-publications-v1` | `api-product-woo-publications-v2` | `api-product-kvk-lookup-v1` | -| name | WOO Publications API | WOO Publications API | KVK Lookup API | -| productSlug | `woo-publications` | `woo-publications` | `kvk-lookup` | -| version | 1.0.0 | 2.0.0 | 1.0.0 | -| status | deprecated | active | active | -| sunsetDate | 2026-10-01T00:00:00+00:00 | — | — | -| visibility | public | public | private | -| defaultTier | free | free | gold | -| tiers | `{free:{rateLimit:{requestsPerWindow:60,windowSeconds:60}},gold:{rateLimit:{requestsPerWindow:600,windowSeconds:60},requiresApproval:true}}` | same shape | `{gold:{rateLimit:{requestsPerWindow:1000,windowSeconds:60},requiresApproval:true}}` | - -### Schema: `api_product_subscription` - -| Field | Object 1 | Object 2 | -|-------|----------|----------| -| slug | `sub-acme-woo-v2-free` | `sub-acme-kvk-gold-pending` | -| product | (woo-publications v2 uuid) | (kvk-lookup v1 uuid) | -| consumer | (existing seeded Consumer uuid) | (existing seeded Consumer uuid) | -| tier | free | gold | -| status | active | pending_approval | - -**Related items per object:** none (Files/Notes/Tasks/Contacts not -applicable to this domain). - -## Trade-offs - -- Chose flat versioned rows over nested version arrays (Decision 1) — - trades a small amount of query-time joining (resolve "the deprecated - version of product X" by `productSlug` + `status`) for consistency with - every other entity in this register and a much simpler tier-resolution - lookup. -- Chose scoped (product-attached-only) inbound logging over universal - inbound logging — trades "analytics only exist for product-fronted - endpoints" for avoiding an unbounded volume/retention change to every - endpoint in the app (see discovery.md Risk Uncovered). diff --git a/openspec/changes/api-product-gateway/discovery.md b/openspec/changes/api-product-gateway/discovery.md deleted file mode 100644 index a0dbecee9..000000000 --- a/openspec/changes/api-product-gateway/discovery.md +++ /dev/null @@ -1,143 +0,0 @@ -# Discovery: api-product-gateway - -## Question - -The context brief assumes gateway latency percentiles can be "computed from -existing CallLog inbound entries" and that per-tier rate-limit policy should -"extend the existing `InboundRateLimitService`". Both are underspecified at -HEAD: does `call_log` already carry enough inbound rows/fields for -percentiles, and what's the correct extension seam on -`InboundRateLimitService` that doesn't fork it? - -## Approach Taken - -- Read `lib/Service/RateLimit/InboundRateLimitService.php` in full. -- Read the `consumer-management`, `endpoint-runtime`, and `prometheus-metrics` - specs in full, plus ADR-003 (CallLog is the primary observability surface). -- Grepped every `'direction' =>` write site in `lib/` to find every place - that produces an inbound `call_log` row. -- Read `lib/Service/EndpointService.php`'s `enforceInboundRateLimit()` / - `recordInboundThrottle()` / `handleRequest()` (the RateLimit-header - choke point). -- Read the `call_log` schema block in `lib/Settings/integriq_register.json` - and the outbound `responseTime` write site in `CallService::buildResponseData()`. -- Read `openspec/specs/openconnector-storage-migration/spec.md` to confirm - `call_log` is now an OpenRegister object (not the legacy `lib/Db/CallLog.php` - entity, which no longer exists on disk). -- Read the archived `2026-07-14-consumer-apikey-enforcement` proposal and - `lib/Service/AuthorizationService::getResolvedConsumer()` to confirm how a - request's Consumer is resolved today. -- Read `lib/Service/ApprovalService.php` and the archived - `2026-07-15-hitl-approval-rule-action` `approval-workflow` spec in full to - find a subject-agnostic (non-FlowToken) approval seam. -- Read `lib/Observability/IntegriqMetricsProvider.php` and - `src/manifest.json`'s `observability.metrics` block to find the declarative - vs. escape-hatch split for Prometheus gauges. -- Read `lib/Settings/register.d/hitl-approval-rule-action.json` and - `register.d/99-source-secrets-writeonly.json` to confirm the register - fragment mechanism can both add new schemas and deep-merge new fields onto - an existing schema. -- Read `src/manifest.json`'s `pages` array to confirm the SPA is - manifest-driven (index/detail/custom page types), not hand-routed Vue views. - -## Findings - -1. **`call_log` inbound rows are NOT a general request log today — brief's - assumption is wrong.** The only code path that writes a `direction: - inbound` `call_log` row is `EndpointService::recordInboundThrottle()`, - called exclusively from `enforceInboundRateLimit()` on the 429 branch - (`REQ-CON-RL-004`). Every *successful* inbound endpoint request writes - nothing to `call_log`. There is no `responseTime`, `endpoint`, or - `product` field on the schema at all — only `statusCode`, `statusMessage`, - `direction`, `created`. Computing latency percentiles "from existing - CallLog inbound entries" is therefore not possible without first adding - general-purpose inbound logging with a duration field. **Deviation from - brief, followed the code**: this change adds inbound `call_log` writes - (with a new `responseTime` field) for every request dispatched through an - `api_product`-scoped endpoint — not every endpoint, to bound volume - growth (see design.md Decision 3 and proposal.md Risk 1). - -2. **`InboundRateLimitService::enforce()` is already policy-agnostic** — it - takes a `consumerKey` string and plain `rateLimit`/`quota` arrays, with no - knowledge of Consumer, Endpoint, or any product concept. The correct - "extend, don't fork" seam is one level up, in - `EndpointService::enforceInboundRateLimit()`, which today derives its - `$key`/`$rateLimit`/`$quota` from the resolved Consumer. Adding a - tier-resolution step ahead of that derivation (falling back to the - existing Consumer-level values when no product/tier applies) requires - zero changes to `InboundRateLimitService` itself. - -3. **`ApprovalService::suspend()` cannot be reused as-is for subscription - approval.** It's tightly coupled to a `FlowToken` snapshot and an - in-flight endpoint rule-pipeline suspension (`EndpointService:: - doHandleRequest()`'s `JSONResponse` short-circuit). But - `ApprovalService::suspendForSynchronization()` already establishes the - pattern this change needs: create a `pending` `approval_request` with an - empty `snapshot`, no FlowToken, notify the `approverGroup`, and let a - *different* subject (a Synchronization batch gate there; an - `api_product_subscription` here) resolve on `approve()`/`reject()`. - `completeApproval()`, `reject()`, `isAuthorizedApprover()`, - `assertActionable()`, `sweepExpired()`, and `notifyApprovers()` are - already fully generic (no FlowToken coupling). Only the *creation* - method needs a subscription-specific sibling of - `suspendForSynchronization()`. - -4. **Prometheus gauges split cleanly into two existing mechanisms.** - `src/manifest.json`'s declarative `observability.metrics[].source.kind: - "tableCount"` (with `groupBy`) already produces `calls_total{status, - direction}` from `call_log` — extending `groupBy` to include a `product` - label is a manifest-only change once `call_log.product` exists. But a - *percentile* is not a row count — it requires sorting/indexing values - within a group — so it cannot be expressed by the declarative - `tableCount`/`objectCount`/`orAvailable` `source.kind` vocabulary. This is - exactly the situation `circuit_breaker_state` already solved: it uses - `source.kind: "provider"`, resolved to `IntegriqMetricsProvider` - (`OCA\OpenRegister\AppHost\IMetricsProvider::integriq` container - alias). Latency percentiles will use the same escape hatch. - -5. **The register fragment mechanism (ADR-037, - `lib/Settings/register.d/*.json`) supports both new-schema declaration - and deep-merge onto an existing schema's `properties`** — - `99-source-secrets-writeonly.json` deep-merges `writeOnly: true` onto five - existing `source` properties without touching the monolith. This is the - documented reason to avoid editing `integriq_register.json` directly - (a `SchemaMapper` `$ref`-resolution bug on re-parse). The new - `api_product`/`api_product_subscription` schemas and the three new - `call_log` fields (`product`, `endpoint`, `responseTime`) will all ship in - one fragment for this change, following the `hitl-approval-rule-action` - precedent of one fragment per change. - -6. **The SPA is manifest-driven**, not hand-routed Vue views — `src/ - manifest.json`'s `pages` array declares `index` (generic list), - `detail` (generic detail), and `custom` (bespoke component) page types. - `Consumers` is a plain `index` page over the `consumer` schema; `Approvals` - is `custom` because it needs non-CRUD actions (approve/reject) and a - scoped API. `API Products` needs the same: a plain index for browsing, but - the detail view needs endpoint-picker + tier editor + analytics panel + - subscription-approval actions that a generic CRUD detail can't express — - it will be `custom`, mirroring `ApprovalDetail`. - -## Recommendation - -Proceed with the approach in proposal.md/design.md: extend -`EndpointService`'s inbound-rate-limit call site (not the service), extend -`ApprovalService` with one new creation method (not the FlowToken-coupled -`suspend()`), add general-but-scoped inbound `call_log` logging restricted to -product-attached endpoints, and split analytics across the declarative -`groupBy` mechanism (counts) and the `IMetricsProvider` escape hatch -(percentiles). All four extension seams already exist in the codebase for -an analogous purpose — none require inventing a new pattern. - -## Risks Uncovered - -- Scoping inbound logging to product-attached endpoints only (rather than - every endpoint) means a consumer's plain (non-product) endpoint calls stay - invisible to analytics — acceptable per proposal.md's in-scope framing - ("API Products GROUP them"; analytics is a product-level, not - endpoint-level, capability), but worth flagging: a future "make analytics - available to every endpoint" change would need to revisit the volume - trade-off in Risk 1. - -## Next Steps - -Proceed to design.md and the spec deltas. diff --git a/openspec/changes/api-product-gateway/migration.md b/openspec/changes/api-product-gateway/migration.md deleted file mode 100644 index 103b634bc..000000000 --- a/openspec/changes/api-product-gateway/migration.md +++ /dev/null @@ -1,116 +0,0 @@ -# Migration: api-product-gateway - -## Current State - -`integriq_register.json` (loaded via `ConfigurationService::importFromApp`, -`openconnector-storage-migration`) declares 15 core schemas, including -`call_log` with `uuid`, `statusCode`, `statusMessage`, `direction` -(`inbound`|`outbound`), `request`, `response`, `sourceId`/`source`, -`actionId`, `synchronizationId`/`synchronization`, `userId`, `sessionId`, -`expires`, `created`, `size`. There is no `api_product` or -`api_product_subscription` schema. `call_log` carries no `product`, -`endpoint`, or top-level `responseTime` field. `lib/Settings/register.d/` -holds one fragment per prior change (`hitl-approval-rule-action.json`, -`99-source-secrets-writeonly.json`, etc.), merged at load per ADR-037. - -## Target State - -- New register.d fragment `lib/Settings/register.d/api-product-gateway.json` - declaring: - - `api_product` schema (new). - - `api_product_subscription` schema (new). - - A deep-merge onto the existing `call_log` schema's `properties` adding - `product` (uuid FK → `api_product`, `onDelete: SET_NULL`), `endpoint` - (uuid FK → `endpoint`, `onDelete: SET_NULL`), and `responseTime` - (integer, milliseconds). -- No existing `call_log` row is touched — the three new fields are simply - absent (`null`/undefined) on every row written before this change deploys; - `endpoint-runtime` `REQ-EP-009`'s new inbound logging only affects rows - written *after* deploy, for endpoints attached to an `api_product` (which - cannot exist before this change either, since the schema is new). - -## Migration Class - -``` -Version: N/A — no PHP Nextcloud migration class needed. -File: none. -Key operations: -- Register fragments are merged into the in-memory register definition at - application boot / `ConfigurationService::importFromApp()` time (see - openconnector-storage-migration#REQ — "Migration class MUST provision the - register via importFromApp"), which already re-runs on every app upgrade - and is idempotent. Adding a new register.d/*.json file requires no new - Version*.php migration class — the existing migration class that calls - importFromApp picks it up automatically on next `occ upgrade` / - `occ app:update integriq`. -``` - -No `Version*.php` class is added by this change. The existing storage -migration's `postSchemaChange` hook already re-imports the full merged -register (base + all `register.d/*.json` fragments) on every upgrade, -per `openconnector-storage-migration` "Migration class MUST provision the -register via importFromApp" — idempotent by design (re-running is a no-op -for schemas/fields that already exist, additive for new ones). - -## Migration Steps - -1. Add `lib/Settings/register.d/api-product-gateway.json` with the two new - schema declarations and the `call_log` deep-merge block (verifiable: - `git diff` shows only new-file addition + no edits to - `integriq_register.json`). -2. Deploy the app version carrying the fragment; on `occ app:update - integriq` (or fresh `occ app:enable`), the existing migration class - re-runs `ConfigurationService::importFromApp()`, which merges the - fragment into the live OpenRegister register/schema tables (verifiable: - `oc_openregister_schemas` gains 2 rows for `api_product` and - `api_product_subscription`; the existing `call_log` schema row's - `properties` JSON gains the 3 new keys, in place — no new row). -3. No data backfill is needed or attempted — existing `call_log` rows simply - have the 3 new fields absent; nothing reads them as required (all three - are optional/nullable and every read site added by this change - null-coalesces). - -## Data Impact - -- **Records affected:** 0 existing rows are modified. The 2 new schemas - start empty. The `call_log` schema **definition** row gains 3 optional - properties; existing `call_log` **object** rows are untouched (their - `object` JSON blobs are not rewritten — OpenRegister schemas are - additive-by-default for optional properties, no `NOT NULL` backfill - required). -- **Data loss:** none. -- **Live-data safe:** yes — purely additive schema changes; no column type - changes, no destructive DDL. Import runs within the existing idempotent - `importFromApp()` call already exercised on every upgrade. - -## Rollback Procedure - -1. Remove `lib/Settings/register.d/api-product-gateway.json` and redeploy - the prior app version. -2. On the next `importFromApp()` run, the 2 new schemas remain registered - (OpenRegister does not auto-drop schemas absent from a re-import by - default) but become unreferenced/orphaned — acceptable for a rollback, - since no other code path depends on their absence. If a clean rollback of - the schema rows themselves is required, an operator runs OpenRegister's - existing schema-deletion tooling manually against `api_product` and - `api_product_subscription` (out of band — this change does not ship an - automated schema-deletion step, consistent with every other register.d - fragment in this app, none of which ship a reverse migration either). -3. The `call_log` deep-merged properties (`product`, `endpoint`, - `responseTime`) similarly remain on the schema definition but stop being - written to once the code that populates them (`EndpointService`'s new - logging) is reverted — no functional impact, since they were - optional/nullable throughout. - -## Validation - -- `SELECT COUNT(*) FROM oc_openregister_schemas WHERE title IN ('Api - Product', 'Api Product Subscription');` → expect `2` after deploy. -- `SELECT properties::jsonb ? 'responseTime' FROM oc_openregister_schemas - WHERE slug = 'call_log';` (Postgres) → expect `true` after deploy. -- Re-run `occ app:update integriq` a second time → expect no error, no - duplicate schema rows (idempotency, per - openconnector-storage-migration's "Idempotent re-run" scenario). -- Create one `api_product` and one `api_product_subscription` via the OR - generic object API → expect both persist and are retrievable, confirming - the fragment merged correctly. diff --git a/openspec/changes/api-product-gateway/proposal.md b/openspec/changes/api-product-gateway/proposal.md index ade624c28..2b22d3d81 100644 --- a/openspec/changes/api-product-gateway/proposal.md +++ b/openspec/changes/api-product-gateway/proposal.md @@ -1,165 +1,44 @@ -# Proposal: api-product-gateway - -## Summary - -Integriq already lets an administrator define individual inbound -`Endpoint`s and gate them behind a `Consumer`'s authentication, per-consumer -rate limit, and quota (`consumer-management`, `endpoint-runtime`). It has no -concept of an **API Product** — a named, versioned bundle of endpoints that a -Consumer can discover and subscribe to at a rate-limit tier, with an optional -approval gate. This change adds API Products, tiered subscriptions, per-tier -rate-limit enforcement, RFC 8594 deprecation headers on sunset product -versions, and gateway analytics (request count, error rate, and p50/p95/p99 -latency per product) — the multi-tenancy and analytics table stakes every -competing API gateway ships. - -## Motivation - -Specter user-story synthesis for the API-product cluster surfaces five -recurring asks: "create an API product definition in the gateway", "rate -limit per consumer tier", "view current rate-limit usage per consumer", -"gateway latency percentiles per API product", and "deprecate an old API -version". `consumer-rate-limiting` (archived) already delivered per-consumer -limits; `consumer-apikey-enforcement` (archived 2026-07-14) just made -Consumer-backed apiKey auth real. What is still missing is the *product* -layer on top: grouping endpoints into a sellable/discoverable unit, giving -each subscribing consumer a named tier instead of one flat per-consumer -limit, and surfacing usage/latency so an operator can actually run the -gateway as a product. - -## Affected Projects - -- [x] Project: `integriq` — new `api_product` and - `api_product_subscription` OR schemas, extended `call_log` schema, extended - `InboundRateLimitService` call site (not the service itself), - `EndpointService` dispatch (deprecation headers + product-scoped inbound - logging), `ApprovalService` (one new subscription-approval creation - method), `IntegriqMetricsProvider` (latency percentile gauges), SPA - manifest (`API Products` page). - -## Scope - -### In Scope - -1. `api_product` OR schema: a named, versioned bundle of `Endpoint`s (by - uuid) with a `visibility` (public/private), a set of named `tiers` (each - carrying its own `rateLimit`/`quota`, mirroring the existing Consumer - shape, plus a `requiresApproval` flag), a `defaultTier`, and a - `status`/`sunsetDate` pair for version deprecation. -2. `api_product_subscription`: a Consumer's subscription to a Product at a - named tier, gated by an approval workflow when the tier requires it - (reusing `hitl-approval-rule-action`'s `ApprovalService` state machine — - see design.md Decision 4), auto-activated otherwise. -3. Per-tier rate-limit/quota enforcement at the endpoint runtime: extends - `InboundRateLimitService::enforce()`'s call site in `EndpointService` - (the service itself is unchanged) to resolve a subscription's tier policy - ahead of the existing consumer-level policy, keyed on - `(consumer, product, tier)` so product-tier counters are independent of a - consumer's plain per-endpoint counters. A request past its tier's - `rateLimit`/`quota` receives HTTP 429 exactly like today's consumer-level - 429 (`REQ-CON-RL-002`/`003`). -4. Gateway analytics: per-product request count and error rate (declarative - Prometheus gauge, extending the existing `calls_total` groupBy) and - p50/p95/p99 latency (AppHost provider escape hatch, computed from - inbound `call_log.responseTime` — see design.md Decision 3), surfaced on - a new **API Products** SPA page and via `/api/metrics`. -5. API version deprecation: marking an `api_product` `status: deprecated` - with a `sunsetDate` makes every response served through that product's - endpoints carry `Sunset` (RFC 8594) and `Deprecation` headers. -6. Tests: PHPUnit for tier-policy resolution, percentile calculation, and - Sunset/Deprecation header emission; Newman for consumer-over-tier → 429 - and deprecated-product → header scenarios. - -### Out of Scope - -- A full self-service developer portal or API-key issuance UI for - prospective consumers — follow-up, filed as an issue at apply time. -- Monetization/billing on top of tiers. -- Multi-version endpoint routing (an endpoint moving between product - versions) — a product version references the endpoints that exist today; - endpoint versioning itself is unchanged. - -## Approach - -New OR schemas (`api_product`, `api_product_subscription`) shipped as a -per-change `register.d` fragment (ADR-037), deep-merging two new fields -(`product`, `endpoint`) plus a `responseTime` field onto the existing -`call_log` schema. Tier-policy resolution is a new private method in -`EndpointService` that runs *before* today's `enforceInboundRateLimit()` and -substitutes its `rateLimit`/`quota` inputs and cache key — `enforce()` on -`InboundRateLimitService` is not touched. Subscription approval reuses -`ApprovalService`'s generic `approval_request` state machine via one new -creation method mirroring the existing `suspendForSynchronization()` -(no FlowToken, no rule-pipeline coupling). Deprecation headers reuse the -existing `handleRequest()` header-merge choke point that already attaches -`RateLimit-*` headers. Analytics split across the two existing observability -mechanisms: declarative `groupBy` for counts, the `IMetricsProvider` -escape hatch for percentiles (the same split the codebase already uses for -`calls_total` vs `circuit_breaker_state`). - -## New Dependencies - -None. No new packages, libraries, or external services. - -## Impact - -- **Schema**: `integriq_register.json` gains `product`/`endpoint`/ - `responseTime` on `call_log` (register.d fragment). New `api_product`, - `api_product_subscription` schemas. -- **Backend**: `lib/Service/EndpointService.php` (tier-policy resolution, - deprecation headers, product-scoped inbound logging), - `lib/Service/ApprovalService.php` (one new method), - `lib/Observability/IntegriqMetricsProvider.php` (percentile gauges), - `src/manifest.json` (declarative `calls_total` groupBy extension + new - metric descriptor). -- **Frontend**: new `ApiProducts` (index) and `ApiProductDetail` (custom) - manifest pages, new `ConnectionsGroup` menu entry. -- **No changes** to `InboundRateLimitService`, the `consumer` schema, or any - existing endpoint's dispatch behaviour when it is not part of a product. - -## Cross-Project Dependencies - -None. This is entirely within Integriq; the API Products surface is -consumed by external API clients, not by other apps-extra projects. - -## Risks - -### Risk 1: Inbound call_log volume growth from product-scoped logging - -**Severity:** Medium — **Mitigation:** Logging is scoped to endpoints that -belong to an `api_product` only (today's non-product endpoints keep their -current behaviour — only 429s are logged, per `REQ-CON-RL-004`). Retention -follows the existing `expires` convention (ADR-004); no new retention floor -is introduced. - -### Risk 2: Percentile computation cost at scrape time - -**Severity:** Medium — **Mitigation:** Bounded per-product row window (last -N inbound rows, consistent with `REQ-PROM-007`'s top-100 cardinality cap and -`REQ-PROM-001`'s 500ms scrape budget); a query failure falls back to a -zero-value sample, matching every other `REQ-PROM-*` degraded-not-broken -pattern. - -### Risk 3: Two overlapping rate-limit keys per consumer - -**Severity:** Low — **Mitigation:** Product-tier keys -(`product:{uuid}:consumer:{key}`) are namespaced separately from plain -consumer keys (`consumer:{key}` / `ip:{addr}`) in the same distributed -cache, so they cannot collide or double-count against each other. - -## Rollback Strategy - -Revert the `register.d` fragment (drops the two new schemas and the three -new `call_log` fields — additive fields, no data loss for existing rows), -revert the `EndpointService`/`ApprovalService`/`IntegriqMetricsProvider` -changes, and remove the manifest page/menu entries. No endpoint that is not -attached to an `api_product` observes any behaviour change, so rollback is -safe on a live instance with active traffic. - -## Open Questions - -- Should a `api_product_subscription` in `pending_approval` block the - consumer from calling the product's endpoints entirely, or fall back to - the product's `defaultTier`? Resolved in design.md Decision 2 — it - blocks (403), consistent with "subscribe" implying opt-in access, not - ambient access. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: api-product-gateway (superseded — retired 2026-09-02) + +This directory double-counted a change that had already shipped. The API +product gateway was implemented and archived on 2026-07-15 +(`archive/2026-07-15-api-product-gateway`, 21/33 tasks checked with +per-task evidence), yet this live copy was resurrected at 0/33: the +openconnector→integriq rename applied to the prose, the evidence notes +stripped, every box reset. The machinery exists at HEAD: +`lib/Controller/ProductSubscriptionsController.php`, the api_product +routes in `appinfo/routes.php`, the schema fragment in +`lib/Settings/register.d/api-product-gateway.json`, gateway enforcement in +`lib/Service/EndpointService.php`, product metrics in +`lib/Observability/IntegriqMetricsProvider.php` (the fleet rename moved it +from `OpenConnectorMetricsProvider.php` — a move, not a gap), and the UI +(manifest pages `ApiProducts` / `ApiProductDetail`, +`src/views/ApiProducts/ApiProductDetail.vue`). + +No live `@spec` tags point into this directory (`appinfo/routes.php` +mentions it in a prose comment only, which this retirement keeps valid by +leaving the directory in place). + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `api_product` schema + tiers, subscription lifecycle (subscribe/approve/reject), over-tier enforcement + deprecation headers in the endpoint runtime, analytics, product metrics, API Products pages + Consumer subscription widget, seed data | **Already shipped and archived**: `archive/2026-07-15-api-product-gateway` (21/33 boxes checked), code at HEAD | +| Residual verification: live schema-import and seed run, Playwright for the API Products pages and Consumer widget, Newman for subscribe/approve/reject/analytics + 429 + deprecation headers, feature docs, screenshot, `nl_NL` catalog entries | Open, and honestly unticked in the archived twin (no live instance in that session; each open box carries its reason). Same shape as `approvals-verification-pack`; pick up in a verification pass, not by resurrecting this change | + +## Sequencing + +Nothing remains to implement from this change directly. The residual +live-instance verification, docs and l10n belong to a +verification-pack-style follow-up. + +## Archival + +This directory is retired in place (not moved or renamed) to keep the diff +reviewable and the prose pointer in `appinfo/routes.php` valid; archive it +via the normal flow at the next sweep. diff --git a/openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md b/openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md deleted file mode 100644 index 3ef8e8268..000000000 --- a/openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md +++ /dev/null @@ -1,250 +0,0 @@ -# api-product-gateway Specification - -**Status**: in-progress -**Scope**: integriq -**OpenSpec changes**: -- [api-product-gateway](../../changes/api-product-gateway/) - -## Purpose - -Integriq exposes individual `Endpoint`s gated by `Consumer` auth and -per-consumer rate limits (`consumer-management`, `endpoint-runtime`), but has -no concept of a **API Product** — a named, versioned bundle of endpoints a -consumer can subscribe to at a rate-limit tier, with gateway analytics and -version-deprecation signalling. This capability adds that product layer on -top of the existing endpoint/consumer primitives, per ADR-008 (polymorphic -target dispatch is unchanged — products group existing endpoints, they do -not introduce a new target kind). - -## ADDED Requirements - -### Requirement: API Product groups Endpoints into a named, versioned bundle (REQ-APG-001) - -The system MUST provide an `api_product` OpenRegister schema representing a -named bundle of existing `Endpoint`s at a specific `version`, with a -`productSlug` grouping multiple version-rows of the same logical product, a -`visibility` (`public`|`private`), a `status` (`active`|`deprecated`), and a -`tiers` map of named rate-limit/quota policies with a `defaultTier`. An -`api_product`'s `endpoints` array MUST reference existing `Endpoint` uuids; -creating or updating an `api_product` MUST NOT create, modify, or delete the -`Endpoint`s it references. - -@e2e exclude backend schema definition — covered by PHPUnit, no browser UI - -#### Scenario: an API Product groups multiple endpoints - -- GIVEN three existing `Endpoint`s serving `/publications`, `/publications/{id}`, and `/publications/{id}/attachments` -- WHEN an administrator creates an `api_product` with `productSlug: "woo-publications"`, `version: "2.0.0"`, and those three endpoint uuids in `endpoints` -- THEN the `api_product` is persisted with all three endpoint uuids AND none of the three `Endpoint` objects are modified - -#### Scenario: a product version is independent of other versions of the same product - -- GIVEN two `api_product` rows sharing `productSlug: "woo-publications"` — one `version: "1.0.0"`, one `version: "2.0.0"` -- WHEN the `1.0.0` row's `endpoints` array is edited -- THEN the `2.0.0` row's `endpoints` array is unaffected - -### Requirement: API Products management UI (REQ-APG-002) - -Integriq MUST provide an **API Products** section in its SPA where an -administrator can browse, create, edit, and delete API Products, pick which -existing Endpoints belong to a product, and define/edit its named tiers. - -#### Scenario: API Products list page mounts and shows content - -- GIVEN an authenticated admin visits the integriq app -- WHEN they navigate to the API Products section via the sidebar nav or direct URL `/apps/integriq/products` -- THEN the API Products index page renders inside the main content area with content visible - -#### Scenario: product detail page exposes an endpoint picker and tier editor - -- GIVEN at least one `api_product` and at least one `Endpoint` exist -- WHEN the administrator opens the product's detail page -- THEN they can add/remove Endpoints from the product's `endpoints` array and add/edit named tiers with a `rateLimit`/`quota`/`requiresApproval` configuration - -### Requirement: Consumer subscribes to an API Product at a tier (REQ-APG-003) - -The system MUST let a Consumer be subscribed to an `api_product` at one of -its named `tiers` via `POST /api/products/{productId}/subscriptions`, -creating an `api_product_subscription` referencing the product, the -consumer, and the chosen tier. The chosen tier MUST exist in the product's -`tiers` map; an unknown tier MUST be rejected with HTTP 400. - -@e2e exclude backend subscription creation — covered by Newman, not browser UI - -#### Scenario: subscribing to a tier that requires no approval activates immediately - -- GIVEN an `api_product` with a `free` tier where `requiresApproval` is absent (falsy) -- WHEN a Consumer subscribes at the `free` tier -- THEN an `api_product_subscription` is created with `status: active` and HTTP 201 is returned - -#### Scenario: subscribing to an unknown tier is rejected - -- GIVEN an `api_product` whose `tiers` map contains only `free` and `gold` -- WHEN a subscription request names tier `platinum` -- THEN the response is HTTP 400 and no `api_product_subscription` is created - -### Requirement: Subscription approval gate reuses the HITL ApprovalService (REQ-APG-004) - -When the chosen tier's `requiresApproval` is `true`, subscribing MUST create -the `api_product_subscription` with `status: pending_approval`, create a -`pending` `approval_request` via `ApprovalService::suspendForSubscription()` -(no `FlowToken` snapshot — see design.md Decision 4), notify the configured -`approverGroup`, and return HTTP 202 with the subscription id and the -approval_request id. Approving the request MUST flip the subscription's -`status` to `active` and stamp `activatedAt`; rejecting it MUST flip it to -`rejected`. A subscription that is not `active` MUST NOT receive its tier's -rate-limit/quota policy (`REQ-APG-005`); requests from a consumer with no -`active` subscription to a product's endpoint MUST receive HTTP 403. - -@e2e exclude backend approval-gated subscription flow — covered by PHPUnit/Newman, not browser UI - -#### Scenario: a gold tier requiring approval creates a pending subscription - -- GIVEN an `api_product` whose `gold` tier has `requiresApproval: true` -- WHEN a Consumer subscribes at the `gold` tier -- THEN an `api_product_subscription` is created with `status: pending_approval`, a `pending` `approval_request` is created, the configured `approverGroup` is notified, and HTTP 202 is returned - -#### Scenario: approving the request activates the subscription - -- GIVEN a `pending_approval` subscription with its linked `pending` `approval_request` -- WHEN an authorized approver approves the request -- THEN the subscription's `status` becomes `active` with `activatedAt` set - -#### Scenario: a pending subscription grants no access - -- GIVEN a Consumer with only a `pending_approval` subscription to a product -- WHEN that consumer calls one of the product's endpoints -- THEN the response is HTTP 403 and no rate-limit policy from that product is applied - -### Requirement: Per-tier rate-limit enforcement extends the inbound rate limiter (REQ-APG-005) - -The system MUST, for a request to an `Endpoint` that belongs to an -`api_product`, resolve the caller's `active` subscription to that product -and enforce the subscription's tier `rateLimit`/`quota` via the existing -`InboundRateLimitService::enforce()` (unmodified — see design.md Decision 5), -keyed on `(consumer, product)` so product-tier counters never share a bucket -with the consumer's plain per-endpoint counters. A request exceeding the -tier's `rateLimit.requestsPerWindow` or `quota.limit` MUST receive HTTP 429 -with the same `RateLimit-*`/`Retry-After` header contract as -`consumer-management` `REQ-CON-RL-003`. When the endpoint is not part of any -`api_product`, or the consumer has no `active` subscription to that product, -today's Consumer-level `rateLimit`/`quota` (`REQ-CON-RL-002`) applies -unchanged. - -@e2e exclude backend enforcement — covered by PHPUnit/Newman, not browser UI - -#### Scenario: over-tier request returns 429 - -- GIVEN an `active` subscription at the `free` tier (`rateLimit {requestsPerWindow: 2, windowSeconds: 60}`) -- WHEN the subscribed consumer makes 3 requests to the product's endpoint within the same window -- THEN the first 2 succeed and the 3rd receives HTTP 429 with `Retry-After` - -#### Scenario: product-tier counters are independent of the consumer's own rateLimit - -- GIVEN a Consumer with its own `rateLimit {requestsPerWindow: 100, windowSeconds: 60}` AND an `active` subscription to a product's `free` tier `{requestsPerWindow: 2, windowSeconds: 60}` -- WHEN the consumer calls the product's endpoint 3 times in the window -- THEN the 3rd request receives HTTP 429 from the tier limit even though the consumer's own 100-request budget is far from exhausted - -#### Scenario: a non-product endpoint is unaffected - -- GIVEN an `Endpoint` that belongs to no `api_product` -- WHEN its consumer calls it repeatedly within its own `rateLimit` -- THEN enforcement follows `consumer-management` `REQ-CON-RL-002` exactly as before this change - -### Requirement: Deprecated product version carries Sunset and Deprecation headers (REQ-APG-006) - -The system MUST, when an `api_product`'s `status` is `deprecated`, ensure -every response served through any of that product's `endpoints` carries a -`Deprecation: true` header and a `Sunset` header (RFC 8594, HTTP-date -format) reflecting the product's `sunsetDate`. An `api_product` with -`status: active` MUST NOT add either header. - -@e2e exclude backend response headers — covered by Newman, not browser UI - -#### Scenario: a deprecated product version's endpoint responses carry Sunset and Deprecation - -- GIVEN an `api_product` with `status: deprecated` and `sunsetDate: "2026-10-01T00:00:00+00:00"`, grouping an endpoint `/publications` -- WHEN a request is served through `/publications` -- THEN the response carries `Deprecation: true` and `Sunset: Thu, 01 Oct 2026 00:00:00 GMT` - -#### Scenario: an active product version's endpoint responses carry neither header - -- GIVEN an `api_product` with `status: active` grouping an endpoint `/publications` -- WHEN a request is served through `/publications` -- THEN the response carries neither `Deprecation` nor `Sunset` - -#### Scenario: an endpoint shared by an active and a deprecated version reflects only the version it was dispatched through - -- GIVEN `productSlug: "woo-publications"` has a `deprecated` `1.0.0` row and an `active` `2.0.0` row, each grouping its own endpoint set -- WHEN a request is served through the `1.0.0` row's endpoint -- THEN Deprecation/Sunset headers are present, regardless of `2.0.0`'s status - -### Requirement: Gateway analytics per API Product (REQ-APG-007) - -The system MUST compute, per `api_product`, a request count, an error rate -(share of requests with `statusCode >= 400`), and p50/p95/p99 response-time -latency percentiles from inbound `call_log` rows carrying that product's -uuid (see `endpoint-runtime` `REQ-EP-009` for how those rows are produced), -and surface them both on the API Products detail page -(`GET /api/products/{productId}/analytics`) and as Prometheus gauges (see -`prometheus-metrics` `REQ-PROM-012`/`REQ-PROM-013`). - -@e2e exclude backend analytics computation — covered by PHPUnit, no browser UI - -#### Scenario: analytics reflect recent traffic - -- GIVEN a product with 100 recorded inbound `call_log` rows in the last hour, 5 with `statusCode >= 400` -- WHEN `GET /api/products/{productId}/analytics` is called -- THEN `requestCount` is 100 and `errorRate` is 0.05 - -#### Scenario: latency percentiles are computed from responseTime - -- GIVEN a product's recent inbound `call_log` rows with `responseTime` values ranging 10ms-500ms -- WHEN the analytics endpoint (or the Prometheus scrape) computes percentiles -- THEN `p50`/`p95`/`p99` reflect the 50th/95th/99th percentile of the recorded `responseTime` values - -#### Scenario: a product with no recorded traffic reports zero, not an error - -- GIVEN a newly created product with no inbound `call_log` rows yet -- WHEN analytics are requested -- THEN `requestCount` is 0, `errorRate` is 0, and latency percentiles are 0 — no error is raised - -## Non-Functional Requirements - -- **Performance:** analytics computation (both the REST endpoint and the - Prometheus provider) is bounded to the most recent 1000 inbound rows per - product and completes within the existing `REQ-PROM-001` 500ms scrape - budget. -- **Accessibility:** the API Products SPA pages meet WCAG 2.2 AA — endpoint - picker and tier editor controls carry accessible labels (`NcSelect` - `inputLabel`, per the established `ncvue` gotcha); analytics charts carry - a text-equivalent summary (request count / error rate / percentiles as - plain text alongside any chart). -- **Internationalization:** Dutch and English MUST be supported (hydra - ADR-007) for all new SPA strings (product/tier labels, subscription - status, deprecation notices). - -## Acceptance Criteria - -- [ ] An administrator can create an `api_product`, pick its endpoints, and - define tiers with independent rate-limit/quota policies. -- [ ] A consumer can subscribe to a product at a tier; approval-gated tiers - block access until approved. -- [ ] An over-tier request receives HTTP 429 without affecting the - consumer's own plain rate limit. -- [ ] A deprecated product version's endpoints carry Sunset/Deprecation - headers; an active version's do not. -- [ ] Per-product request count, error rate, and p50/p95/p99 latency are - visible on the product detail page and via `/api/metrics`. - -## Notes - -- Self-service API key issuance / a public developer portal is explicitly - out of scope (proposal.md) — filed as a follow-up issue at apply time. -- See `discovery.md` for the verified-against-HEAD finding that inbound - `call_log` did not previously carry general request data — this - capability's analytics requirement (`REQ-APG-007`) depends on the new - product-scoped inbound logging added by `endpoint-runtime` `REQ-EP-009`. -- Related ADRs: ADR-003 (CallLog is the primary observability surface), - ADR-008 (polymorphic endpoint target dispatch, unchanged), ADR-037 - (register fragments). diff --git a/openspec/changes/api-product-gateway/specs/consumer-management/spec.md b/openspec/changes/api-product-gateway/specs/consumer-management/spec.md deleted file mode 100644 index b16304c57..000000000 --- a/openspec/changes/api-product-gateway/specs/consumer-management/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -# consumer-management Specification (Delta) - -## ADDED Requirements - -### Requirement: Consumer detail surfaces its API Product subscriptions (REQ-CON-SUB-001) - -The Consumer detail view in the *Consumers* section MUST list the -consumer's `api_product_subscription` rows (product name, tier, status), -read-only, alongside the authentication and rate-limit/quota configuration -it already renders (`REQ-CON-RL-005`). This requirement adds visibility -only; subscription creation/approval happens on the API Products pages -(`api-product-gateway` `REQ-APG-003`/`REQ-APG-004`), not here. - -@e2e exclude consumer detail subscription list — Playwright regression added in the implementation phase alongside the existing Consumer detail journey - -#### Scenario: an operator sees a consumer's active and pending subscriptions - -- GIVEN a Consumer with one `active` subscription to "WOO Publications API" at tier `free` and one `pending_approval` subscription to "KVK Lookup API" at tier `gold` -- WHEN the operator opens that Consumer's detail view -- THEN both subscriptions are listed with their product name, tier, and status - -#### Scenario: a consumer with no subscriptions shows an empty state - -- GIVEN a Consumer with no `api_product_subscription` rows -- WHEN the operator opens that Consumer's detail view -- THEN the subscriptions section renders an empty state, not an error - -### Requirement: Per-product-tier policy takes precedence over the consumer-level rate limit (REQ-CON-SUB-002) - -The inbound rate-limit/quota policy applied MUST be the subscription's tier -policy, not the Consumer's own `rateLimit`/`quota` (`REQ-CON-RL-001`), when -a request targets an `Endpoint` that belongs to an `api_product` and the -resolved consumer has an `active` `api_product_subscription` to that -product. The Consumer's own `rateLimit`/`quota` remains the -policy for every other endpoint the same consumer calls that is not part of -that product. This requirement states the precedence rule from the -Consumer's perspective; the resolution mechanism lives in `endpoint-runtime` -and is specified by `api-product-gateway` `REQ-APG-005`. - -@e2e exclude backend precedence rule — covered by PHPUnit, no browser UI - -#### Scenario: tier policy overrides the consumer's own rate limit on a product endpoint - -- GIVEN a Consumer with `rateLimit {requestsPerWindow: 1000, windowSeconds: 60}` AND an `active` subscription to a product's `free` tier `{requestsPerWindow: 2, windowSeconds: 60}` -- WHEN that consumer calls the product's endpoint -- THEN the `free` tier's 2-requests-per-window limit is enforced, not the consumer's 1000-requests-per-window limit - -#### Scenario: the consumer's own rate limit still governs non-product endpoints - -- GIVEN the same Consumer as above, also calling an unrelated `Endpoint` that belongs to no `api_product` -- WHEN that consumer calls the unrelated endpoint -- THEN the consumer's own `rateLimit {requestsPerWindow: 1000, windowSeconds: 60}` is enforced unchanged diff --git a/openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md b/openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md deleted file mode 100644 index 60333375a..000000000 --- a/openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md +++ /dev/null @@ -1,78 +0,0 @@ -# endpoint-runtime Specification (Delta) - -## ADDED Requirements - -### Requirement: Deprecated-product-version dispatch attaches Sunset/Deprecation headers (REQ-EP-008) - -The system MUST, via `EndpointService::handleRequest()`'s existing -header-merge choke point (the same one that attaches the -`RateLimit-*`/`Retry-After` headers per `consumer-management` -`REQ-CON-RL-003`), when the dispatched endpoint belongs to an `api_product` -whose `status` is `deprecated`, merge a `Deprecation: true` header and a -`Sunset` header (RFC 8594, HTTP-date format derived from the product's -`sunsetDate`) into the response, for every method and every dispatch path -(simple fast-path `REQ-EP-002` and full pipeline `REQ-EP-003` alike). This -is the runtime mechanism backing `api-product-gateway` `REQ-APG-006`; this -requirement documents where in the dispatch pipeline it is wired, not the -product-level contract itself. - -@e2e exclude backend header attachment — covered by Newman, no browser UI - -#### Scenario: the fast path also carries deprecation headers - -- GIVEN a "simple" endpoint (`REQ-EP-002`) that belongs to a `deprecated` `api_product` -- WHEN a GET request is served via the fast path -- THEN the response carries `Deprecation: true` and `Sunset` despite bypassing the full rule pipeline - -#### Scenario: the full pipeline path also carries deprecation headers - -- GIVEN a non-simple endpoint (`REQ-EP-003`) that belongs to a `deprecated` `api_product` -- WHEN a request runs the full before/dispatch/after pipeline -- THEN the response carries `Deprecation: true` and `Sunset` alongside any rule-produced headers - -#### Scenario: an endpoint in no product carries neither header - -- GIVEN an endpoint that belongs to no `api_product` -- WHEN a request is dispatched -- THEN the response carries neither `Deprecation` nor `Sunset` — no change from pre-change behaviour - -### Requirement: Inbound observability logging for API-product-scoped endpoints (REQ-EP-009) - -The system MUST, for a request dispatched through an `Endpoint` that -belongs to at least one `api_product`, persist a `direction: inbound` -`call_log` row on completion (success or error) carrying the resolved -`product` uuid, -the dispatched `endpoint` uuid, the final `statusCode`, and a `responseTime` -in milliseconds measured from dispatch start to response ready — extending -the existing 429-only inbound logging (`consumer-management` -`REQ-CON-RL-004`) to every outcome, but scoped to product-attached endpoints -only (an endpoint in no `api_product` continues to log inbound rows only on -429, exactly as today). The write MUST be best-effort: a logging failure -MUST NOT block or alter the response (same pattern as -`recordInboundThrottle()`). - -@e2e exclude backend inbound logging — covered by PHPUnit, no browser UI - -#### Scenario: a successful product-scoped request is logged with duration - -- GIVEN an endpoint that belongs to an `api_product` -- WHEN a request to it completes with HTTP 200 in 42ms -- THEN a `call_log` row is persisted with `direction: inbound`, `statusCode: 200`, `product: `, `endpoint: `, and `responseTime: 42` - -#### Scenario: an errored product-scoped request is logged too - -- GIVEN an endpoint that belongs to an `api_product` -- WHEN a request to it fails with HTTP 500 -- THEN a `call_log` row is persisted with `direction: inbound`, `statusCode: 500`, and the product/endpoint linkage, so it counts toward that product's error rate - -#### Scenario: a non-product endpoint's successful requests are still not logged - -- GIVEN an endpoint that belongs to no `api_product` -- WHEN a request to it completes with HTTP 200 -- THEN no `call_log` row is persisted for it (unchanged from pre-change behaviour; only its 429s would be, per `REQ-CON-RL-004`) - -#### Scenario: a logging failure never blocks the response - -- GIVEN the `call_log` write raises an exception (e.g. OpenRegister temporarily unavailable) -- WHEN a product-scoped request otherwise succeeds -- THEN the response is still returned successfully and the logging failure is recorded only in the application log diff --git a/openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md b/openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md deleted file mode 100644 index 7b0be26b3..000000000 --- a/openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md +++ /dev/null @@ -1,70 +0,0 @@ -# prometheus-metrics Specification (Delta) - -## ADDED Requirements - -### Requirement: Per-API-Product request and error gauges (REQ-PROM-012) - -The app MUST expose `integriq_api_product_requests_total` as a gauge -with labels `product` (the `api_product`'s `productSlug`) and `status` -(HTTP status code), and `integriq_api_product_errors_total` as a gauge -with label `product`, both computed declaratively from inbound `call_log` -rows carrying a `product` uuid (`endpoint-runtime` `REQ-EP-009`), the same -`source.kind: "tableCount"` + `groupBy` mechanism that already produces -`calls_total{status,direction}` (`REQ-PROM-005`) — extended with a `product` -label, resolved from the `call_log.product` uuid to its `productSlug` via -the existing label-resolution join pattern. - -#### Scenario: request counts exposed per product and status - -- GIVEN 40 inbound `call_log` rows for product `woo-publications` with status 200 and 3 with status 429 -- WHEN the metrics endpoint is called -- THEN the output includes `integriq_api_product_requests_total{product="woo-publications",status="200"} 40` and `...{product="woo-publications",status="429"} 3` - -#### Scenario: error count reflects statusCode >= 400 rows - -- GIVEN a product with 100 inbound rows, 5 with `statusCode >= 400` -- WHEN the metrics endpoint is called -- THEN `integriq_api_product_errors_total{product=""} 5` - -#### Scenario: a product with no inbound traffic emits a zero placeholder - -- GIVEN an `api_product` with no inbound `call_log` rows yet -- WHEN the metrics endpoint is called -- THEN `integriq_api_product_requests_total{product="",status="200"} 0` is emitted, consistent with every other `REQ-PROM-*` zero-placeholder scenario - -### Requirement: Per-API-Product latency percentile gauges (REQ-PROM-013) - -The app MUST expose `integriq_api_product_latency_seconds` as a gauge -with labels `product` and `quantile` (`0.5`|`0.95`|`0.99`), produced by the -`IntegriqMetricsProvider` `IMetricsProvider` escape hatch (the same -mechanism `circuit_breaker_state` uses, `REQ-PROM-011`) — a percentile -cannot be expressed by the declarative `tableCount`/`objectCount` `groupBy` -vocabulary used by `REQ-PROM-012`, since it requires sorting values within a -group rather than counting rows. Per product, the provider MUST read at -most the most recent 1000 inbound `call_log` rows carrying that product's -uuid and compute p50/p95/p99 from their `responseTime` values (milliseconds, -converted to seconds for the gauge per Prometheus convention). - -#### Scenario: latency gauge exposes p50/p95/p99 per product - -- GIVEN a product's 1000 most recent inbound `call_log` rows with `responseTime` ranging 10-500ms -- WHEN the metrics endpoint is called -- THEN the output includes `integriq_api_product_latency_seconds{product="",quantile="0.5"}`, `...quantile="0.95"`, and `...quantile="0.99"` reflecting those percentiles in seconds - -#### Scenario: a product with no traffic reports zero latency, not a missing series - -- GIVEN a product with zero inbound `call_log` rows -- WHEN the metrics endpoint is called -- THEN all three quantile samples for that product are emitted as `0`, not omitted - -#### Scenario: provider query failure falls back to zero, degraded not broken - -- GIVEN the `call_log` query for a product's percentile computation raises an exception -- WHEN the metrics endpoint collects this metric -- THEN a zero-value fallback is emitted with a warning logged, and the overall endpoint still returns HTTP 200 (same degraded-but-not-broken contract as `REQ-PROM-001`'s partial-failure scenario and `REQ-PROM-011`'s query-failure scenario) - -#### Scenario: percentile computation stays within the scrape performance budget - -- GIVEN 50 active `api_product` rows each with up to 1000 inbound rows -- WHEN the metrics endpoint is called -- THEN percentile computation for all products completes within the existing `REQ-PROM-001` 500ms budget (bounded row count per product, in-memory sort, no additional joins) diff --git a/openspec/changes/api-product-gateway/tasks.md b/openspec/changes/api-product-gateway/tasks.md index 4d8c0cc4f..409b83071 100644 --- a/openspec/changes/api-product-gateway/tasks.md +++ b/openspec/changes/api-product-gateway/tasks.md @@ -1,127 +1,9 @@ -# Tasks: api-product-gateway - -## Implementation Tasks - -### Task 1: Add the api-product-gateway register fragment (schema/migration) -- **spec_ref**: `openspec/changes/api-product-gateway/migration.md`, `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-product-groups-endpoints-into-a-named-versioned-bundle-req-apg-001` -- **files**: `lib/Settings/register.d/api-product-gateway.json` -- **acceptance_criteria**: - - GIVEN the app is upgraded WHEN `occ app:update integriq` runs THEN `api_product` and `api_product_subscription` schemas are registered AND `call_log` gains `product`/`endpoint`/`responseTime` properties without any existing `call_log` row being modified - - GIVEN the migration re-runs a second time WHEN `occ app:update integriq` runs again THEN no error occurs and no duplicate schema rows are created -- [ ] Implement -- [ ] Test - -### Task 2: Resolve per-tier rate-limit policy ahead of the inbound limiter -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-per-tier-rate-limit-enforcement-extends-the-inbound-rate-limiter-req-apg-005`, `openspec/changes/api-product-gateway/specs/consumer-management/spec.md#requirement-per-product-tier-policy-takes-precedence-over-the-consumer-level-rate-limit-req-con-sub-002` -- **files**: `lib/Service/EndpointService.php` -- **acceptance_criteria**: - - GIVEN a consumer with an active subscription to a product's `free` tier `{requestsPerWindow:2,windowSeconds:60}` WHEN it makes 3 requests to the product's endpoint in one window THEN the 3rd receives HTTP 429 with `Retry-After` - - GIVEN the same consumer also calling an endpoint outside any product WHEN it calls that endpoint THEN its own Consumer-level `rateLimit` applies unchanged - - GIVEN a request to an endpoint in no `api_product` WHEN it is dispatched THEN behaviour is byte-for-byte identical to before this change (no regression on `REQ-CON-RL-002`) -- [ ] Implement -- [ ] Test - -### Task 3: Add Sunset/Deprecation headers for deprecated product versions -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-deprecated-product-version-carries-sunset-and-deprecation-headers-req-apg-006`, `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-deprecated-product-version-dispatch-attaches-sunset-deprecation-headers-req-ep-008` -- **files**: `lib/Service/EndpointService.php` -- **acceptance_criteria**: - - GIVEN an endpoint that belongs to an `api_product` with `status: deprecated` and a `sunsetDate` WHEN a request is served (fast path or full pipeline) THEN the response carries `Deprecation: true` and `Sunset: ` - - GIVEN an endpoint that belongs to an active or no product WHEN a request is served THEN neither header is present -- [ ] Implement -- [ ] Test - -### Task 4: Log inbound requests for product-scoped endpoints -- **spec_ref**: `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-inbound-observability-logging-for-api-product-scoped-endpoints-req-ep-009` -- **files**: `lib/Service/EndpointService.php` -- **acceptance_criteria**: - - GIVEN an endpoint that belongs to an `api_product` WHEN a request completes (2xx or error) THEN a `call_log` row is persisted with `direction: inbound`, the product/endpoint uuids, `statusCode`, and `responseTime` - - GIVEN an endpoint in no `api_product` WHEN a successful request completes THEN no `call_log` row is written for it (unchanged from today — only its 429s are logged) - - GIVEN the `call_log` write throws WHEN a product-scoped request otherwise succeeds THEN the response is still returned and the failure is only logged -- [ ] Implement -- [ ] Test - -### Task 5: Add subscription-approval creation to ApprovalService -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-subscription-approval-gate-reuses-the-hitl-approvalservice-req-apg-004`, `openspec/changes/api-product-gateway/design.md#decision-4-subscription-approval-reuses-approvalservices-generic-state-machine-via-one-new-creation-method-not-suspend` -- **files**: `lib/Service/ApprovalService.php` -- **acceptance_criteria**: - - GIVEN a tier with `requiresApproval: true` WHEN `suspendForSubscription()` is called THEN a `pending` `approval_request` is created with no FlowToken snapshot and the configured `approverGroup` is notified -- [ ] Implement -- [ ] Test - -### Task 6: Add ProductSubscriptionsController (subscribe / approve / reject / analytics) -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-consumer-subscribes-to-an-api-product-at-a-tier-req-apg-003`, `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-subscription-approval-gate-reuses-the-hitl-approvalservice-req-apg-004`, `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-gateway-analytics-per-api-product-req-apg-007` -- **files**: `lib/Controller/ProductSubscriptionsController.php`, `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN `POST /api/products/{id}/subscriptions` with a tier that requires no approval WHEN called THEN HTTP 201 and `status: active` are returned - - GIVEN the same call with a tier requiring approval WHEN called THEN HTTP 202, `status: pending_approval`, and an `approvalRequestId` are returned - - GIVEN an unknown tier name WHEN subscribing THEN HTTP 400 is returned and no subscription is created - - GIVEN `GET /api/products/{id}/analytics` on a product with no traffic WHEN called THEN `requestCount: 0`, `errorRate: 0`, all percentiles `0` — no error -- [ ] Implement -- [ ] Test - -### Task 7: Add per-product latency percentile gauges (AppHost provider escape hatch) -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-latency-percentile-gauges-req-prom-013` -- **files**: `lib/Observability/IntegriqMetricsProvider.php` -- **acceptance_criteria**: - - GIVEN a product's inbound `call_log` rows with varying `responseTime` WHEN `/api/metrics` is scraped THEN `integriq_api_product_latency_seconds{product,quantile}` reflects p50/p95/p99 in seconds - - GIVEN a product with zero traffic WHEN scraped THEN all three quantile samples are `0`, not omitted - - GIVEN the underlying query throws WHEN scraped THEN a zero-value fallback is emitted with a warning logged and the endpoint still returns HTTP 200 -- [ ] Implement -- [ ] Test - -### Task 8: Extend declarative request/error gauges with a product label -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-request-and-error-gauges-req-prom-012` -- **files**: `src/manifest.json` -- **acceptance_criteria**: - - GIVEN inbound `call_log` rows carrying a `product` uuid WHEN `/api/metrics` is scraped THEN `integriq_api_product_requests_total{product,status}` and `integriq_api_product_errors_total{product}` are exposed - - GIVEN a product with no traffic WHEN scraped THEN a zero-value placeholder is emitted -- [ ] Implement -- [ ] Test - -### Task 9: Add the API Products SPA pages (index + detail) -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-products-management-ui-req-apg-002` -- **files**: `src/manifest.json`, `src/views/ApiProducts/ApiProductDetail.vue` -- **acceptance_criteria**: - - GIVEN an authenticated admin WHEN they navigate to `/apps/integriq/products` THEN the API Products index page renders with content visible - - GIVEN a product's detail page WHEN opened THEN the admin can add/remove endpoints and add/edit named tiers, and see the analytics panel (request count, error rate, p50/p95/p99) and pending subscriptions with approve/reject actions -- [ ] Implement -- [ ] Test - -### Task 10: Surface subscriptions on the Consumer detail view -- **spec_ref**: `openspec/changes/api-product-gateway/specs/consumer-management/spec.md#requirement-consumer-detail-surfaces-its-api-product-subscriptions-req-con-sub-001` -- **files**: `src/manifest.json` (Consumer detail config), or the Consumer detail component it references -- **acceptance_criteria**: - - GIVEN a consumer with active and pending subscriptions WHEN its detail view is opened THEN both are listed with product name, tier, and status - - GIVEN a consumer with no subscriptions WHEN its detail view is opened THEN an empty state renders, not an error -- [ ] Implement -- [ ] Test - -### Task 11: Seed data for api_product and api_product_subscription -- **spec_ref**: `openspec/changes/api-product-gateway/design.md#seed-data` -- **files**: `lib/Settings/register.d/api-product-gateway.json` (`x-openregister-seed`) -- **acceptance_criteria**: - - GIVEN a fresh install WHEN the app is enabled THEN 3 `api_product` rows (one deprecated) and 2 `api_product_subscription` rows (one active, one pending_approval) exist, using the general organization/publications domain data already established by this app's other seeds -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/Service/EndpointServiceTierPolicyTest.php`, `tests/Unit/Service/ApprovalServiceSubscriptionTest.php`, `tests/Unit/Observability/IntegriqMetricsProviderTest.php`, `tests/Unit/Controller/ProductSubscriptionsControllerTest.php`) -- [ ] Newman/Postman tests for the new API endpoints (subscribe/approve/reject/analytics; over-tier 429; deprecated-product headers) -- [ ] Browser tests (Playwright MCP) for the API Products index/detail pages and the Consumer detail subscription list -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` (API Products concept, tier configuration, deprecation headers) -- [ ] Screenshot captured and committed to `docs/images/` - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new SPA strings (product/tier labels, subscription status, deprecation notices, analytics panel) +# Tasks: api-product-gateway (superseded) + +The original 11-task / 33-checkbox list was removed with the 2026-09-02 +retirement (see proposal.md for the disposition; the list survives in +`archive/2026-07-15-api-product-gateway/tasks.md`, where 21/33 boxes are +checked with per-task evidence, and in git history). The residual +live-instance verification, docs and `nl_NL` l10n work is listed there with +per-box reasons and belongs to a verification-pack-style follow-up. There +is nothing to implement from this change directly. diff --git a/openspec/changes/api-product-gateway/test-plan.md b/openspec/changes/api-product-gateway/test-plan.md deleted file mode 100644 index ed8977d9e..000000000 --- a/openspec/changes/api-product-gateway/test-plan.md +++ /dev/null @@ -1,237 +0,0 @@ -# Test Plan: api-product-gateway - -## Test Cases - -### TC-1: API Product groups endpoints without mutating them -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-product-groups-endpoints-into-a-named-versioned-bundle-req-apg-001` -- **type**: api -- **preconditions**: three existing `Endpoint`s -- **steps**: create an `api_product` referencing all three endpoint uuids -- **expected result**: the product persists with all three uuids; none of the three `Endpoint` objects change -- **test command**: /test-api - -### TC-2: Product versions are independent -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-product-groups-endpoints-into-a-named-versioned-bundle-req-apg-001` -- **type**: api -- **preconditions**: two `api_product` rows sharing a `productSlug`, different `version` -- **steps**: edit one version's `endpoints` array -- **expected result**: the other version's `endpoints` array is unaffected -- **test command**: /test-api - -### TC-3: API Products index page renders -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-products-management-ui-req-apg-002` -- **type**: functional -- **preconditions**: authenticated admin session -- **steps**: navigate to `/apps/integriq/products` via sidebar nav -- **expected result**: index page renders inside main content area with content visible -- **test command**: /test-functional - -### TC-4: Product detail exposes endpoint picker and tier editor -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-api-products-management-ui-req-apg-002` -- **type**: functional -- **preconditions**: one `api_product` and one `Endpoint` exist -- **steps**: open the product's detail page; add an endpoint; add a tier with a rateLimit -- **expected result**: the endpoint appears in the product's `endpoints`; the tier persists with its policy -- **test command**: /test-functional - -### TC-5: Endpoint picker and tier editor are accessible -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#non-functional-requirements` -- **type**: accessibility -- **preconditions**: product detail page loaded -- **steps**: run WCAG 2.2 AA audit against the endpoint picker (`NcSelect`) and tier editor controls -- **expected result**: no missing accessible-name violations; `inputLabel`/`ariaLabelCombobox` present on every `NcSelect` -- **test command**: /test-accessibility - -### TC-6: Subscribing to a no-approval tier activates immediately -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-consumer-subscribes-to-an-api-product-at-a-tier-req-apg-003` -- **type**: api -- **preconditions**: product with a `free` tier, `requiresApproval` absent -- **steps**: `POST /api/products/{id}/subscriptions` with `{consumerId, tier:"free"}` -- **expected result**: HTTP 201, `status: active` -- **test command**: /test-api - -### TC-7: Subscribing to an unknown tier is rejected -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-consumer-subscribes-to-an-api-product-at-a-tier-req-apg-003` -- **type**: api -- **preconditions**: product with tiers `free`/`gold` only -- **steps**: `POST /api/products/{id}/subscriptions` with `tier:"platinum"` -- **expected result**: HTTP 400, no subscription created -- **test command**: /test-api - -### TC-8: Approval-gated tier creates a pending subscription and notifies approvers -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-subscription-approval-gate-reuses-the-hitl-approvalservice-req-apg-004` -- **type**: api -- **preconditions**: product with a `gold` tier, `requiresApproval: true`, `approverGroup: "gateway-approvers"` -- **steps**: `POST /api/products/{id}/subscriptions` with `tier:"gold"` -- **expected result**: HTTP 202, `status: pending_approval`, `approvalRequestId` present; every member of `gateway-approvers` receives an NC notification -- **test command**: /test-api - -### TC-9: Approving a subscription activates it -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-subscription-approval-gate-reuses-the-hitl-approvalservice-req-apg-004` -- **type**: api -- **preconditions**: a `pending_approval` subscription with its linked `pending` approval_request -- **steps**: an authorized approver calls the approve action -- **expected result**: subscription `status` becomes `active`, `activatedAt` set -- **test command**: /test-api - -### TC-10: A pending subscription grants no access -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-subscription-approval-gate-reuses-the-hitl-approvalservice-req-apg-004` -- **type**: api -- **preconditions**: consumer with only a `pending_approval` subscription to a product -- **steps**: call the product's endpoint -- **expected result**: HTTP 403 -- **test command**: /test-api - -### TC-11: Over-tier request returns 429 without exhausting the consumer's own limit -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-per-tier-rate-limit-enforcement-extends-the-inbound-rate-limiter-req-apg-005`, `openspec/changes/api-product-gateway/specs/consumer-management/spec.md#requirement-per-product-tier-policy-takes-precedence-over-the-consumer-level-rate-limit-req-con-sub-002` -- **type**: api -- **preconditions**: consumer with `rateLimit{1000,60}` AND an active subscription to a product's `free` tier `{2,60}` -- **steps**: call the product's endpoint 3 times within one window -- **expected result**: requests 1-2 succeed, request 3 returns HTTP 429 with `Retry-After`; the consumer's own 1000-budget is unaffected -- **test command**: /test-api - -### TC-12: Non-product endpoints are unaffected by tier enforcement -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-per-tier-rate-limit-enforcement-extends-the-inbound-rate-limiter-req-apg-005` -- **type**: regression -- **preconditions**: an endpoint that belongs to no `api_product` -- **steps**: call it repeatedly within the consumer's own `rateLimit` -- **expected result**: behaviour identical to pre-change `REQ-CON-RL-002` -- **test command**: /test-regression - -### TC-13: Deprecated product version emits Sunset/Deprecation headers -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-deprecated-product-version-carries-sunset-and-deprecation-headers-req-apg-006`, `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-deprecated-product-version-dispatch-attaches-sunset-deprecation-headers-req-ep-008` -- **type**: api -- **preconditions**: `api_product` with `status: deprecated`, `sunsetDate` set, grouping an endpoint -- **steps**: call the endpoint -- **expected result**: response carries `Deprecation: true` and `Sunset: ` -- **test command**: /test-api - -### TC-14: Active product version emits neither header (both dispatch paths) -- **spec_ref**: `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-deprecated-product-version-dispatch-attaches-sunset-deprecation-headers-req-ep-008` -- **type**: api -- **preconditions**: one simple (fast-path) and one full-pipeline endpoint, both in an `active` product -- **steps**: call each -- **expected result**: neither response carries `Deprecation` or `Sunset` -- **test command**: /test-api - -### TC-15: Product-scoped requests are logged with duration (success and error) -- **spec_ref**: `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-inbound-observability-logging-for-api-product-scoped-endpoints-req-ep-009` -- **type**: api -- **preconditions**: an endpoint in an `api_product` -- **steps**: call it once successfully, once forcing a 500 -- **expected result**: two `call_log` rows persisted with `direction:inbound`, product/endpoint uuids, correct `statusCode`, and a positive `responseTime` -- **test command**: /test-api - -### TC-16: Non-product endpoint successful requests remain unlogged -- **spec_ref**: `openspec/changes/api-product-gateway/specs/endpoint-runtime/spec.md#requirement-inbound-observability-logging-for-api-product-scoped-endpoints-req-ep-009` -- **type**: regression -- **preconditions**: an endpoint in no `api_product` -- **steps**: call it successfully -- **expected result**: no new `call_log` row for that call -- **test command**: /test-regression - -### TC-17: Gateway analytics reflect recent traffic -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-gateway-analytics-per-api-product-req-apg-007` -- **type**: api -- **preconditions**: a product with a mix of recorded inbound rows (some errors) -- **steps**: `GET /api/products/{id}/analytics` -- **expected result**: `requestCount`, `errorRate`, and `latency.p50/p95/p99` reflect the recorded rows -- **test command**: /test-api - -### TC-18: Analytics for a traffic-free product report zero, not an error -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-gateway-analytics-per-api-product-req-apg-007` -- **type**: api -- **preconditions**: newly created product, no `call_log` rows -- **steps**: `GET /api/products/{id}/analytics` -- **expected result**: `requestCount:0`, `errorRate:0`, all percentiles `0`, HTTP 200 -- **test command**: /test-api - -### TC-19: Per-product Prometheus request/error gauges -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-request-and-error-gauges-req-prom-012` -- **type**: api -- **preconditions**: inbound rows across two products, mixed status codes -- **steps**: `GET /api/metrics` -- **expected result**: `integriq_api_product_requests_total{product,status}` and `integriq_api_product_errors_total{product}` present with correct counts; zero-value placeholder for a traffic-free product -- **test command**: /test-api - -### TC-20: Per-product Prometheus latency percentile gauges -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-latency-percentile-gauges-req-prom-013` -- **type**: api -- **preconditions**: a product with recorded `responseTime` values -- **steps**: `GET /api/metrics` -- **expected result**: `integriq_api_product_latency_seconds{product,quantile}` present for `0.5`/`0.95`/`0.99`, in seconds -- **test command**: /test-api - -### TC-21: Percentile gauge degrades gracefully on query failure -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-latency-percentile-gauges-req-prom-013` -- **type**: api -- **preconditions**: simulate the underlying `call_log` query throwing -- **steps**: `GET /api/metrics` -- **expected result**: zero-value fallback emitted, warning logged, endpoint still returns HTTP 200 -- **test command**: /test-api - -### TC-22: Metrics scrape stays within budget at moderate scale -- **spec_ref**: `openspec/changes/api-product-gateway/specs/prometheus-metrics/spec.md#requirement-per-api-product-latency-percentile-gauges-req-prom-013` -- **type**: performance -- **preconditions**: 50 active products, up to 1000 rows each -- **steps**: `GET /api/metrics`, measure wall time -- **expected result**: completes within the existing 500ms `REQ-PROM-001` scrape budget -- **test command**: /test-performance - -### TC-23: Consumer detail lists active and pending subscriptions -- **spec_ref**: `openspec/changes/api-product-gateway/specs/consumer-management/spec.md#requirement-consumer-detail-surfaces-its-api-product-subscriptions-req-con-sub-001` -- **type**: functional -- **preconditions**: a Consumer with one active and one pending subscription -- **steps**: open that Consumer's detail view -- **expected result**: both subscriptions listed with product name, tier, status -- **test command**: /test-functional - -### TC-24: Consumer detail shows an empty state with no subscriptions -- **spec_ref**: `openspec/changes/api-product-gateway/specs/consumer-management/spec.md#requirement-consumer-detail-surfaces-its-api-product-subscriptions-req-con-sub-001` -- **type**: functional -- **preconditions**: a Consumer with zero subscriptions -- **steps**: open that Consumer's detail view -- **expected result**: empty state renders, no error thrown -- **test command**: /test-functional - -### TC-25: Gateway operator persona — subscribe, over-limit, deprecate end to end -- **spec_ref**: `openspec/changes/api-product-gateway/specs/api-product-gateway/spec.md#requirement-consumer-subscribes-to-an-api-product-at-a-tier-req-apg-003` -- **type**: persona -- **persona**: Mark (MKB Software Vendor integrating against the gateway) -- **preconditions**: a published API Product with tiers -- **steps**: subscribe a consumer at a low tier, exceed its limit, observe the 429 and Retry-After, then have an admin deprecate the product version and re-call the endpoint -- **expected result**: the full lifecycle behaves per REQ-APG-003/005/006 from an integrator's point of view — clear 429 with retry guidance, clear deprecation signal -- **test command**: /test-persona-mark - -## Coverage Summary - -| Requirement | Covered by | -|---|---| -| api-product-gateway REQ-APG-001 | TC-1, TC-2 | -| api-product-gateway REQ-APG-002 | TC-3, TC-4, TC-5 | -| api-product-gateway REQ-APG-003 | TC-6, TC-7, TC-25 | -| api-product-gateway REQ-APG-004 | TC-8, TC-9, TC-10 | -| api-product-gateway REQ-APG-005 | TC-11, TC-12, TC-25 | -| api-product-gateway REQ-APG-006 | TC-13, TC-14, TC-25 | -| api-product-gateway REQ-APG-007 | TC-17, TC-18 | -| consumer-management REQ-CON-SUB-001 | TC-23, TC-24 | -| consumer-management REQ-CON-SUB-002 | TC-11 | -| endpoint-runtime REQ-EP-008 | TC-13, TC-14 | -| endpoint-runtime REQ-EP-009 | TC-15, TC-16 | -| prometheus-metrics REQ-PROM-012 | TC-19 | -| prometheus-metrics REQ-PROM-013 | TC-20, TC-21, TC-22 | - -All 13 ADDED requirements across the 4 spec deltas have at least one covering -test case; every requirement with an error/degraded-path scenario has a -dedicated negative test case (TC-7, TC-10, TC-12, TC-16, TC-18, TC-21). - -## Out of Scope - -- Self-service developer portal / API key issuance UI — out of scope for - this change (proposal.md), no test cases written. -- Monetization/billing on tiers — out of scope, no test cases written. -- Load/soak testing of the distributed rate-limit cache under concurrency - beyond what `consumer-management`'s existing "counters are correct under - concurrency" test already covers for the underlying - `InboundRateLimitService` — this change does not modify that service, so - its concurrency guarantee is inherited, not re-verified here. diff --git a/openspec/changes/cdc-incremental-sync/design.md b/openspec/changes/cdc-incremental-sync/design.md deleted file mode 100644 index 7f5f0b8db..000000000 --- a/openspec/changes/cdc-incremental-sync/design.md +++ /dev/null @@ -1,276 +0,0 @@ -# Design: cdc-incremental-sync - -## Architecture Overview -Incremental sync is a mode flag on the existing Source → Synchronization → -SynchronizationContract triad (ADR-005) — it does not introduce a new -entity or a parallel sync path. It changes three things inside the existing -extern→intern flow in `SynchronizationService`: - -``` -synchronize() - └─ synchronizeExternToIntern() - ├─ Stage 2: getAllObjectsFromSource() → getAllObjectsFromApi() - │ [NEW] when syncMode=incremental: inject stored cursorWatermark - │ into the Twig context already used for {{ data.* }} - │ endpoint templating, extended to sourceConfig.query too - ├─ Stage 4: per-object processSynchronizationObject() loop (unchanged) - ├─ Stage 5: deleteInvalidObjects() gate - │ [NEW] syncMode=incremental short-circuits this call entirely, - │ at the same site fetchComplete (REQ-009/REQ-010) already - │ gates it - └─ end-of-run persistSynchronization() - [NEW] when syncMode=incremental AND fetchComplete: compute and - persist the new cursorWatermark from the fetched records -``` - -This mirrors exactly how `currentPage` (pagination-in-progress) and -`targetLastSynced` (last successful pass) already round-trip through -`$synchronization` as plain array/OR-object fields — `cursorWatermark` is a -third field in that same family, not a new subsystem. - -## Goals / Non-Goals - -**Goals:** -- Let large/high-volume `api` sources skip already-synced records on every - run after the first, via a stored high-watermark cursor. -- Guarantee the watermark can never advance past data the engine has not - durably processed (composes with REQ-009 fetch-completeness). -- Guarantee `deleteInvalidObjects()` never runs against a partial view of - the source (incremental mode is *always* a partial view, by definition). -- Give operators an explicit, auditable way back to a full baseline - (reset-cursor). - -**Non-Goals:** -- Log-based/binlog CDC (no DB-source adapter exists to attach to — see - proposal.md Out of Scope). -- Automatic cursor-field inference. -- Sub-run (per-page) watermark checkpointing — REQ-009's fetch-completeness - signal is already whole-fetch, all-or-nothing; incremental sync composes - with it as-is rather than adding a second, finer-grained completeness - concept. -- Deletion detection for incremental mode by any means (e.g. a "soft" - ratio-based partial guard) — REQ-010's existing ratio guard is explicitly - a *bulk full-fetch* diff mechanism; giving it a partial-fetch input would - silently reintroduce exactly the false-positive deletions the guardrails - change was written to prevent. Incremental mode's deletion answer is "not - supported, use `full` mode periodically or the reset-cursor action." - -## Decisions - -### Decision 1: Cursor watermark storage — Synchronization object field, not a `sync_cursor` object -**Choice:** Add `cursorWatermark` (string) and `syncMode` (string enum -`full`|`incremental`, default `full`) as top-level properties on the -existing `synchronization` schema in -`lib/Settings/integriq_register.json`, alongside the pre-existing -`currentPage` (pagination-in-progress cursor) and `targetLastSynced` fields. - -**Why:** The codebase already has an established, working convention for -exactly this kind of "small piece of per-pass state that belongs to one -Synchronization" data: `currentPage` is read/reset directly on -`$synchronization` inside `getAllObjectsFromApi()` -(`SynchronizationService.php` ~L3941-3980), and `targetLastSynced` is -written directly onto `$synchronization` at the end of -`synchronizeExternToIntern()` (~L1861-1864) via -`persistSynchronization()`. A cursor watermark is the same shape of fact — -one value, one owner, updated at the same point in the same method. Reusing -the field-on-Synchronization pattern means: -- No new OR schema, no new register entry, no new REST surface to fetch/ - list watermarks. -- No new join/lookup on every fetch — the watermark is already in memory - wherever `$synchronization` is (it is loaded once per run via - `toSynchronization()`). -- Rollback is free (see proposal.md Rollback Strategy) — an unset field is - just `null`/absent, exactly like every pre-existing Synchronization today - has no opinion on `currentPage` beyond its default. - -**Alternative considered — separate `sync_cursor` OR object (1 per -Synchronization, or 1 per Synchronization × cursor-field):** Rejected. -A separate object would need its own schema, its own find-or-create -resolution on every run (an extra OR round-trip per sync pass, on the hot -path), and — worse — a second place where "did this run's watermark update -actually commit" could diverge from whether the run itself committed, -reintroducing a variant of the exact split-state problem -sync-safety-guardrails REQ-011 (test runs make no writes) was written to -close for contracts/targets. A dedicated object would earn its keep if -watermarks needed independent versioning/history (e.g. audit trail of every -watermark value ever set) — not needed here; `targetLastSynced` doesn't get -that either, and this field is symmetric with it. - -### Decision 2: Cursor filter injection — extend the existing Twig endpoint-templating context, not a second templating mechanism -**Choice:** `getAllObjectsFromApi()` already Twig-renders -`sourceConfig.endpoint` when it contains `{{`/`}}`, via -`MappingService::renderTemplateString(template: $endpoint, context: ['data' -=> $contextData])` (SynchronizationService.php ~L3888-3904). This change -adds a `cursor` key to that same context — -`context: ['data' => $contextData, 'cursor' => $cursorWatermark]` — so an -admin can write `sourceConfig.endpoint: ".../items?updatedAfter={{ cursor -}}"`. It also extends the identical `{{`/`}}`-detection-then- -`renderTemplateString()` treatment to each scalar value in -`sourceConfig.query` (currently passed through to `$config['query']` -verbatim, untemplated), so an admin can instead write -`sourceConfig.query.updatedAfter: "{{ cursor }}"` when the source takes the -cursor as a query parameter rather than a path/endpoint segment. `cursor` -resolves to an empty string on a synchronization's first-ever incremental -run (no prior watermark) — sources whose API treats an absent/empty cursor -parameter as "give me everything" get a correct first full-ish incremental -baseline for free; sources that require a non-empty value document that in -their `sourceConfig.query` default (e.g. -`sourceConfig.query.updatedAfter: "{{ cursor|default('1970-01-01') }}"`, -which Twig's `default` filter already supports with no engine change). - -**Why:** "Reuse the existing request-config templating" is an explicit -proposal constraint. Endpoint templating is the only templating already -wired into the fetch path; the minimal, lowest-risk change is widening its -context by one key and widening its application from one field (endpoint) -to one more (query values) using the exact same -detect-`{{`-then-`renderTemplateString()` idiom already proven at -L3889-3904 — not introducing a second engine, a second context-building -function, or a bespoke cursor-substitution mini-language. - -**Alternative considered — a dedicated `{{cursor}}` placeholder syntax -resolved by string-replace, bypassing Twig:** Rejected. Twig is already a -hard dependency of this file (`use Twig\Error\LoaderError;` etc. at the top -of `SynchronizationService.php`) and `renderTemplateString()` already -supports filters/defaults for free (as shown above); a bespoke -string-replace would be strictly less capable while adding a second -code path to maintain and explain. - -**Alternative considered — a dedicated `cursorQueryParam` config key that -the engine sets directly into `$config['query']`, no templating:** -Rejected as the sole mechanism (though effectively a special case of the -templating approach still applies) because it cannot express -endpoint-path-segment cursors (e.g. `/items/since/{{ cursor }}`) or -composite values (e.g. a cursor embedded in a JSON request body via -`useDataAsRequestBody`), while the templating approach handles all three -injection points (endpoint, query, and — already possible today with zero -further change, since `$config['body']` is built from `$data` which callers -control — body) through one mechanism. - -### Decision 3: Composition point with the sync-safety guard — the existing `$fetchComplete` local in `synchronizeExternToIntern()` Stage 5 -**Choice:** Both new behaviors attach to the exact code that already exists -at `synchronizeExternToIntern()` Stage 5 (SynchronizationService.php -~L1766-1802): - -```php -$fetchComplete = ($rateLimitException === null && ($fetchInfo['complete'] ?? true)); - -$deletedCount = 0; -$guardInfo = null; -if ($isTest === false) { - // [NEW] incremental mode never runs deletion — checked BEFORE the - // existing fetchComplete-gated call, so it short-circuits deletion - // for its own explicit reason ('incremental_mode') rather than - // reusing fetchComplete's 'fetch_incomplete' reason, which would be - // misleading (the fetch can be perfectly complete for what it asked - // for — it just didn't ask for everything). - $syncMode = (string) ($synchronization['syncMode'] ?? 'full'); - if ($syncMode !== 'incremental') { - $deletedCount = $this->deleteInvalidObjects( - synchronization: $synchronization, - synchronizedTargetIds: $synchronizedTargetIds, - deleteRestriction: $deleteRestriction, - data: $deleteData, - fetchComplete: $fetchComplete, - forceDeletion: ($forceDeletion ?? false), - guardInfo: $guardInfo - ); - } else { - $guardInfo = ['guarded' => true, 'reason' => 'incremental_mode', 'ratio' => null, 'threshold' => null]; - } - - // [NEW] watermark advance — same $fetchComplete boolean REQ-010 - // already computed above; a rate-limited or otherwise incomplete - // fetch (REQ-009) blocks the watermark exactly as it blocks deletion. - if ($syncMode === 'incremental' && $fetchComplete === true) { - $newWatermark = $this->computeCursorWatermark(synchronization: $synchronization, objectList: $objectList); - if ($newWatermark !== null) { - $synchronization['cursorWatermark'] = $newWatermark; - } - } -} -``` - -**Why:** REQ-009 (fetch-completeness tracking) and REQ-010 (deletion -gating) already compute and thread a single `$fetchComplete` boolean to -exactly this point — it is the one place in the method that knows, with -certainty, "did this run's fetch see everything it was supposed to." Both -new invariants (never advance the watermark on an incomplete fetch; never -delete in incremental mode) are correctness rules *about that same fact*, -so attaching them here means: -- There is no way for a future change to update `$fetchComplete`'s - computation (e.g. adding a new failure mode) without both the deletion - guard and the watermark guard picking it up automatically — they read - the same variable. -- The incremental-mode deletion block is unconditional (checked first, not - folded into `$fetchComplete`) so it cannot be defeated by - `forceDeletion: true` the way the ratio guard can — deleting in - incremental mode is not a "the operator explicitly overrode a soft - guard" situation, it is "the data needed to make this decision correctly - was never fetched," which no override can fix (proposal.md Risk 3). -- `deleteInvalidObjects()` itself also gets a defense-in-depth check - (`$synchronization['syncMode'] === 'incremental'` → return 0 immediately, - mirroring its existing `$fetchComplete === false` early-return at - L2533-2551) so a future caller that reaches it directly (bypassing - `synchronizeExternToIntern()`) cannot accidentally delete against a - partial incremental fetch either. - -**Alternative considered — a separate `syncMode`-only guard clause -independent of `$fetchComplete`, placed earlier in the method (e.g. right -after Stage 2 fetch):** Rejected for the watermark half — advancing the -watermark logically depends on the fetch being *complete*, not merely on -mode, so it must read `$fetchComplete` regardless of where it's placed; -placing it right next to the deletion gate (which already needs the same -variable) avoids computing or threading `$fetchComplete` to two different -locations in the method. - -## Risks / Trade-offs -- [Risk] An incremental synchronization whose source has no reliable - monotonic field (flaky clocks, non-monotonic ids) silently misses - records → [Mitigation] Documented in `sourceConfig.cursorField`'s schema - description as an admin responsibility, same as `idPosition` today; - Risk 1 in proposal.md covers the missing-field case specifically (throws - rather than silently skipping). -- [Risk] Extending Twig templating to `sourceConfig.query` values is a - small surface-area increase (any query value containing `{{`/`}}` is now - template-evaluated, not passed through literally) → [Mitigation] Uses the - exact same evaluation function and trust boundary as the pre-existing - endpoint templating (both operate on admin-authored `sourceConfig`, never - on source-returned data), so this does not cross a new trust boundary — - it is the same boundary, one more field. -- [Risk] Operators may expect `reset-cursor` to also retroactively delete - now-possibly-stale target objects, or to restore deletion-based garbage - collection once the next fetch happens to cover the whole source → - [Mitigation] `reset-cursor` only clears `cursorWatermark`; it deliberately - does **not** change `syncMode`. Per Decision 3, `deleteInvalidObjects()` - is skipped unconditionally whenever `syncMode === 'incremental'`, with no - exception for "this particular fetch happened to be full" — the engine - has no reliable way to verify that an admin-templated `{{ cursor }}` - placeholder resolving to an empty string actually caused the source to - return its complete set (that is a semantic guarantee about the source's - API, not something the engine can structurally confirm). An operator who - wants deletion detection back MUST explicitly switch the Synchronization's - `syncMode` to `full` — a separate, deliberate action, not a side effect of - `reset-cursor`. Document both of these (what `reset-cursor` does and does - not do) explicitly in the SPA tooltip/help text (tasks.md). - -## Migration Plan -No Nextcloud database migration — see `migration.md` (skipped, with -rationale) and Decision 1: both new fields are additive, optional JSON -schema properties on an OpenRegister-persisted object, not columns on an -NC-managed table. Deploy is: ship the schema change + code together; -existing Synchronizations are unaffected (`syncMode` absent ⇒ treated as -`full`, byte-identical to current behavior — no code path changes for any -Synchronization that does not explicitly opt into `incremental`). - -## Open Questions -- Should the SPA surface the current `cursorWatermark` value read-only (for - operator visibility/debugging) in addition to the reset action? Deferred - to tasks.md as a small, low-risk addition — not a design decision, since - it changes no backend behavior. -- Should `deleteInvalidObjects()`'s defense-in-depth `syncMode` check log a - warning (mirroring the `fetchComplete === false` branch's warning + - `SynchronizationDeletionGuardedEvent` dispatch) if it is ever actually - reached via a direct caller, or silently return 0? Recommendation: mirror - the existing pattern exactly (warning + event, `reason: - 'incremental_mode'`) for observability parity — captured as a task - acceptance criterion rather than left open at implementation time. diff --git a/openspec/changes/cdc-incremental-sync/proposal.md b/openspec/changes/cdc-incremental-sync/proposal.md index a46328f32..3294be398 100644 --- a/openspec/changes/cdc-incremental-sync/proposal.md +++ b/openspec/changes/cdc-incremental-sync/proposal.md @@ -1,178 +1,38 @@ -# Proposal: cdc-incremental-sync +--- +kind: spec-only +depends_on: [] +--- -## Summary -Add a cursor-based `incremental` sync mode to Integriq's Synchronization -engine, alongside the existing (default, unchanged) `full` hash-diff mode. -When `syncMode: incremental`, an extern→intern run requests only source -records changed since a stored high-watermark cursor (via the engine's -existing Twig request-config templating), advances that watermark only after -a complete, successful fetch, and never runs the source-diff garbage -collection pass (`deleteInvalidObjects()`) — an incremental fetch never sees -the full source set, so absence from one page is not evidence of deletion. -An explicit reset-cursor action clears the watermark and forces the next run -back to a full sync. This closes a competitive gap against Airbyte-style -incremental/CDC sync for large, high-volume sources where full-scan-per-run -is prohibitively expensive. +# Proposal: cdc-incremental-sync (superseded — retired 2026-09-02) -## Motivation -Integriq's current sync model (`SynchronizationService:: -synchronizeExternToIntern()`) always fetches the entire source result set on -every run, computes an order-independent hash per object, and diffs against -stored `SynchronizationContract` hashes to detect changes — a correct but -O(source size) approach on every pass. For large or frequently-polled -sources (e.g. a `nextcloud-table`, TED, or registry-mirror source with tens -of thousands of records) this means every scheduled run re-fetches and -re-hashes records that have not changed since the last run, which is both -slow and — for rate-limited API sources — wasteful of a scarce quota -(`checkRateLimit()`/`rateLimitRemaining` in REQ-002). Airbyte and comparable -integration platforms offer cursor-based incremental sync as a first-class -mode specifically to avoid this. Now is the right time because the -sync-safety-guardrails change (archived 2026-07-14) already added the two -correctness primitives incremental sync must compose with without -regressing: fetch-completeness tracking (REQ-009) and deletion gating -(REQ-010) — this change reuses both rather than inventing parallel ones. +This directory double-counted a change that had already shipped. CDC-style +incremental synchronization was implemented and archived on 2026-07-15 +(`archive/2026-07-15-cdc-incremental-sync`, 16/24 tasks checked with +per-task evidence), yet this live copy was resurrected at 0/24: the +openconnector→integriq rename applied to the prose, the evidence notes +stripped, every box reset. The machinery exists at HEAD: the incremental +sync mode, cursor tracking and full-resync fallback in +`lib/Service/SynchronizationService.php` (23 incremental/CDC references), +the `reset-cursor` handling in +`lib/Controller/SynchronizationsController.php`, and the `syncMode` +configuration keys documented in the register descriptor. -## Affected Projects -- [ ] Project: `integriq` — new `syncMode` field + cursor watermark - field on the Synchronization schema, cursor-filtered fetch path in - `SynchronizationService`, incremental-aware deletion gating, and a - reset-cursor REST action + SPA control. +No live `@spec` tags point into this directory. -## Scope +## Disposition of the original scope -### In Scope -1. `syncMode` on a Synchronization: `full` (current default, unchanged - behavior) | `incremental`. -2. Cursor field configuration: which source field is the cursor (e.g. - `updatedAt`, an id, a page token), a comparator, and the stored - high-watermark value itself, persisted on the Synchronization OR object - (see design.md Decision 1). -3. On an incremental run: inject the stored watermark into the outbound - source request via the engine's existing Twig endpoint-templating - mechanism (extended to `sourceConfig.query` values — design.md Decision - 2), so the source is asked for only records newer than the watermark; - process the returned (delta-only) records through the existing - mapping/write pipeline unchanged; advance the watermark **only** after a - complete, successful fetch (composing with REQ-009's `fetchInfo` — - design.md Decision 3); and **never** invoke `deleteInvalidObjects()` for - an incremental run, regardless of fetch-completeness or deletion ratio — - an incremental fetch is a strict subset of the source, so non-appearance - is not deletion evidence. -4. A reset-cursor action (`POST /api/synchronizations/{id}/reset-cursor`) - that clears the stored watermark so the synchronization's next run - requests an unfiltered (empty-cursor) fetch — full-equivalent for a - source whose templated request treats an absent cursor as "no filter." - This action clears the watermark only; it does **not** change - `syncMode`, and therefore does **not** re-enable `deleteInvalidObjects()` - — that stays hard-disabled for as long as `syncMode` is `incremental` - (see item 3 and design.md Decision 3). Restoring deletion detection - requires explicitly switching `syncMode` back to `full`. -5. Tests: unit coverage for watermark-advances-only-on-complete-fetch, - watermark-does-not-advance-on-incomplete/failed fetch, and - no-deletion-in-incremental-mode; integration coverage for two successive - incremental runs fetching/writing only the delta between them. +| Original scope | Where it went | +| --- | --- | +| Incremental sync mode with change cursor, cursor persistence and reset endpoint, full-resync fallback, deletion-detection interplay with the guardrails, SPA sync-mode fields | **Already shipped and archived**: `archive/2026-07-15-cdc-incremental-sync` (16/24 boxes checked), code at HEAD | +| Residual verification: browser test for the sync-mode fields + reset-cursor action, Newman for `reset-cursor`, feature docs, screenshot | Open, and honestly unticked in the archived twin (no live instance in that pass). Same shape as `approvals-verification-pack`; pick up in a verification pass, not by resurrecting this change | -### Out of Scope -- Log-based CDC (database binlog / WAL tailing) — Integriq has no - DB-source adapter today (`getAllObjectsFromSource()`'s `database` branch - is a documented no-op per the base synchronization-engine spec), so - binlog-based CDC has no source to attach to. Filed as a follow-up once a - DB-source adapter exists. -- Automatic cursor-field discovery/inference from a source's schema — - `cursorField` is admin-configured, same convention as `idPosition` - (REQ-003's `getOriginId()`). -- Per-page/partial watermark checkpointing within a single in-progress run — - the watermark advances once, after the whole run's fetch completes (or not - at all); this is a deliberate consequence of composing with REQ-009's - all-or-nothing fetch-completeness signal, not an oversight. +## Sequencing -## Approach -Add two new fields to the Synchronization OR schema (`syncMode`, -`cursorWatermark`) following the existing convention already used for -`currentPage`/`targetLastSynced` (transient per-pass state stored directly -on the Synchronization object, no separate entity). Branch -`synchronizeExternToIntern()`'s fetch stage on `syncMode`: when -`incremental`, resolve the stored watermark and thread it into the same Twig -context (`{{ cursor }}`) `getAllObjectsFromApi()` already uses for -`{{ data.* }}` endpoint templating, extending that templating to -`sourceConfig.query` values as well (currently endpoint-only). After a -successful, complete fetch, compute the new high-watermark from the fetched -records' configured `cursorField` (a dotted-path extraction mirroring -`getOriginId()`) and persist it onto the Synchronization alongside -`targetLastSynced`. Gate `deleteInvalidObjects()` on `syncMode !== 'incremental'` -at the exact call site that already gates it on `fetchComplete` (REQ-010), -plus a defense-in-depth check inside `deleteInvalidObjects()` itself. Add a -`resetCursor()` controller action mirroring the existing `activate`/ -`deactivate` action pattern on `SynchronizationsController`. +Nothing remains to implement from this change directly. The residual +live-instance verification and docs belong to a verification-pack-style +follow-up. -## New Dependencies -None — reuses the existing Twig (`MappingService::renderTemplateString()`) -templating engine already wired for endpoint substitution; no new package. +## Archival -## Impact -- `lib/Service/SynchronizationService.php`: `synchronizeExternToIntern()`, - `getAllObjectsFromApi()`, `deleteInvalidObjects()`, plus new private - helpers for cursor extraction/persistence. -- `lib/Settings/integriq_register.json`: `synchronization` schema gains - `syncMode` and `cursorWatermark` properties; `sourceConfig`'s free-text - description gains the new recognised keys (`cursorField`, - `cursorComparator`). -- `lib/Controller/SynchronizationsController.php` + `appinfo/routes.php`: - new `resetCursor()` action / route. -- SPA: a "Sync mode" field and "Reset cursor" action on the Synchronization - edit form (src/modals or equivalent — implementation detail for tasks.md). -- `openspec/specs/synchronization-engine/spec.md`: new requirements above - REQ-015 (the current highest numbered requirement in this spec). - -## Cross-Project Dependencies -None — this is entirely internal to Integriq's own sync engine and REST -surface; no other apps-extra project consumes a new API from this change. - -## Risks - -### Risk 1: A misconfigured `cursorField` silently produces a monotonically-wrong watermark -**Severity:** High — **Mitigation:** `cursorField` extraction reuses -`getOriginId()`'s existing dotted-path-lookup-with-throw pattern (REQ-003): -a record missing the configured cursor field throws rather than silently -treating it as the lowest possible cursor value, which would otherwise -cause that record's siblings to be permanently skipped on every subsequent -run. Also require `cursorField` to be an ISO-8601 timestamp or a -lexicographically/numerically comparable value; document the constraint -in the schema description like `idPosition` already is. - -### Risk 2: Deleted-then-recreated source records are invisible to incremental sync -**Severity:** Medium — **Mitigation:** This is an inherent limitation of -cursor-based incremental sync (also true of Airbyte), not something this -change can special-case — document it explicitly in the schema description -and the spec's Notes so admins choose `full` mode for sources where -deletion detection matters, and use the reset-cursor action to periodically -force a full reconciliation pass. - -### Risk 3: Watermark advance and deletion-skip must never regress the sync-safety-guardrails invariants -**Severity:** Medium — **Mitigation:** Both new behaviors are implemented -at the exact call site that already threads REQ-009's `$fetchComplete` -through to REQ-010's deletion gate (`synchronizeExternToIntern()` Stage 5, -`lib/Service/SynchronizationService.php` ~line 1781) rather than as a -parallel code path, so the two concerns cannot drift apart. Unit tests -assert both the incomplete-fetch-blocks-watermark-advance case and the -incremental-mode-blocks-deletion case independently. - -## Rollback Strategy -`syncMode` defaults to `full` on the schema, and every existing -Synchronization object predates this field, so an unset `syncMode` is -treated as `full` — a no-op rollback requires no data migration; reverting -the code change alone restores prior behavior exactly, since no existing -Synchronization can already be in `incremental` mode. If an operator has -already opted synchronizations into `incremental` mode, running the -reset-cursor action (or manually clearing `syncMode`) before rollback avoids -any confusion from a since-orphaned `cursorWatermark` value being read by -older code (which will simply ignore it, as it is an unrecognised field). - -## Open Questions -- Should `cursorComparator` support anything beyond `gt`/`gte` (e.g. a - source-specific opaque page-token comparator that isn't numerically or - lexicographically ordered)? Deferred to design.md Decision 2 — `gt`/`gte` - covers the `updatedAt`-timestamp and monotonic-id cases in scope; a - token-cursor source can still work by treating the token as an opaque - string substituted via `{{ cursor }}` without the engine interpreting its - ordering at all. +This directory is retired in place (not moved or renamed) to keep the diff +reviewable; archive it via the normal flow at the next sweep. diff --git a/openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md b/openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md deleted file mode 100644 index 5426cdca4..000000000 --- a/openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md +++ /dev/null @@ -1,391 +0,0 @@ -# synchronization-engine Specification Delta — cdc-incremental-sync - -This delta adds a cursor-based `incremental` sync mode alongside the -existing (default, unchanged) `full` hash-diff mode. REQ numbers continue -from the highest currently claimed by any capability in this spec — -`hitl-approval-rule-action` claims REQ-015, the highest in use on `main` at -the time this change was authored — so this change takes REQ-016..REQ-019. - -## ADDED Requirements - -### Requirement: Incremental sync mode selects a cursor-filtered fetch request (REQ-016) - -`SynchronizationService` SHALL support `syncMode: incremental` on a -Synchronization, in addition to the existing (default, unchanged) `full` -mode. When `syncMode` is `incremental` and `sourceType` is `api`, -`getAllObjectsFromApi()` MUST make the Synchronization's stored -`cursorWatermark` — or an empty string when no watermark has been set yet — -available as a `cursor` key in the Twig context already passed to -`MappingService::renderTemplateString()` when rendering `sourceConfig.endpoint` -(alongside the existing `data` key). `getAllObjectsFromApi()` MUST also -apply the identical `{{`/`}}`-presence-detection-then-`renderTemplateString()` -treatment already used for `sourceConfig.endpoint` to each scalar value in -`sourceConfig.query`, using the same `['data' => ..., 'cursor' => ...]` -context, so a source that takes its cursor as a query parameter rather than -an endpoint path segment can also reference `{{ cursor }}`. A Synchronization -with `syncMode` absent or `full` MUST take the exact pre-existing code path — -no `cursor` context key is added and `sourceConfig.query` values are passed -through unrendered, unchanged from current behavior. - -#### Scenario: an incremental run injects the stored watermark into a templated endpoint - -- GIVEN a Synchronization with `syncMode: incremental`, `sourceConfig.endpoint: - ".../items?updatedAfter={{ cursor }}"`, and a stored `cursorWatermark` of - `"2026-07-01T00:00:00Z"` -- WHEN `getAllObjectsFromApi()` runs -- THEN the rendered request endpoint is - `.../items?updatedAfter=2026-07-01T00:00:00Z` - -#### Scenario: an incremental run injects the stored watermark into a templated query parameter - -- GIVEN a Synchronization with `syncMode: incremental`, - `sourceConfig.query.updatedAfter: "{{ cursor }}"`, and a stored - `cursorWatermark` of `"42"` -- WHEN `getAllObjectsFromApi()` runs -- THEN the outbound request's `updatedAfter` query parameter is rendered to - `"42"` before the call is made - -#### Scenario: an incremental run with no prior watermark passes an empty cursor - -- GIVEN a Synchronization with `syncMode: incremental` and no - `cursorWatermark` set (its first-ever incremental run) -- WHEN `getAllObjectsFromApi()` runs -- THEN `{{ cursor }}` renders to an empty string -- AND a source whose default/fallback (e.g. `{{ cursor|default('1970-01-01') - }}`, a plain Twig filter requiring no engine change) treats an empty - cursor as "everything" receives an effectively full fetch on this first - incremental run - -#### Scenario: a full-mode run is unaffected - -- GIVEN a Synchronization with `syncMode` absent or `full` -- WHEN `getAllObjectsFromApi()` runs -- THEN the Twig context passed to `sourceConfig.endpoint` templating - contains only `data` (no `cursor` key), and `sourceConfig.query` values - are used exactly as configured, byte-identical to pre-existing behavior - -**Notes:** - -- This requirement extends the fetch-request-shaping mechanics of REQ-002 - (source object fetching and pagination) for the `api` branch only; it does - not change REQ-002's pagination, rate-limiting, or next-page resolution - behavior. -- `sourceConfig.cursorField` (a dotted-path lookup mirroring REQ-003's - `idPosition`/`getOriginId()` convention) identifies which field of a - fetched record is the comparable cursor value; it is read by REQ-017's - watermark computation, not by this requirement. -- Methods: `getAllObjectsFromApi()` (extended), `MappingService:: - renderTemplateString()` (reused, unchanged). - -### Requirement: Cursor watermark advances only after a complete, successful fetch (REQ-017) - -`synchronizeExternToIntern()` MUST, for a Synchronization with `syncMode: -incremental`, compute a new high-watermark value from the fetched records' -configured `sourceConfig.cursorField` and persist it as the Synchronization's -`cursorWatermark` **only when** that run's fetch was marked complete per -REQ-009 (`fetchInfo.complete === true`) **and** no `TooManyRequestsHttpException` -was thrown during the fetch — the same `$fetchComplete` computation REQ-010 -already performs at the same point in the method. When the fetch was -incomplete for any reason (partial pagination, a failed page, a rate-limit -response, or the pagination safety cap), the system MUST NOT persist any -change to `cursorWatermark`, so the next run retries from the same -watermark rather than silently skipping the unfetched remainder. A run -invoked with `isTest: true` MUST NOT persist a watermark change regardless -of fetch completeness, consistent with REQ-011 (test runs make no writes). -A record whose configured `cursorField` resolves to `null` MUST cause the -run to throw, mirroring REQ-003's `getOriginId()` behavior for a missing -`idPosition` — silently computing a watermark from an incomplete field -would risk producing an incorrect (too-low) high-watermark that -permanently skips sibling records on every subsequent run. - -#### Scenario: watermark advances after a complete fetch - -- GIVEN a Synchronization with `syncMode: incremental`, - `sourceConfig.cursorField: "updatedAt"`, and a fetch that completes - successfully, returning records with `updatedAt` values up to - `"2026-07-15T09:00:00Z"` -- WHEN `synchronizeExternToIntern()` finishes the run -- THEN the Synchronization's `cursorWatermark` is persisted as - `"2026-07-15T09:00:00Z"` - -#### Scenario: watermark does not advance after a page failure mid-fetch - -- GIVEN a Synchronization with `syncMode: incremental` and an existing - `cursorWatermark` of `"2026-07-01T00:00:00Z"` -- WHEN a run's fetch is marked incomplete (REQ-009) because page 2 of 3 - returned HTTP 500 -- THEN the Synchronization's `cursorWatermark` remains - `"2026-07-01T00:00:00Z"` after the run, unchanged -- AND the next run requests records with `cursor` still resolving to - `"2026-07-01T00:00:00Z"` - -#### Scenario: watermark does not advance after a 429 rate-limit - -- GIVEN a Synchronization with `syncMode: incremental` -- WHEN the source returns HTTP 429 on the first page of a run -- THEN the run's fetch is treated as incomplete (REQ-009) -- AND the Synchronization's `cursorWatermark` is not modified -- AND the caller still receives the `TooManyRequestsHttpException` as before - (REQ-010's existing behavior for the deletion side is unchanged; this - requirement adds the equivalent guarantee for the watermark side) - -#### Scenario: watermark does not advance for a test run even when the fetch is complete - -- GIVEN a Synchronization with `syncMode: incremental` -- WHEN `POST .../synchronizations/{id}/test` runs and its fetch completes - successfully -- THEN the Synchronization's persisted `cursorWatermark` is unchanged - (REQ-011: test runs persist no Synchronization state) - -#### Scenario: a record missing the configured cursorField throws rather than silently computing a wrong watermark - -- GIVEN a Synchronization with `syncMode: incremental` and - `sourceConfig.cursorField: "updatedAt"` -- WHEN a fetched record has no value at the `updatedAt` path -- THEN the run throws an `Exception` naming the missing cursor field -- AND no `cursorWatermark` change is persisted for that run - -**Notes:** - -- This requirement composes directly with REQ-009/REQ-010's existing - `$fetchComplete` computation in `synchronizeExternToIntern()` — it does - not introduce a second completeness signal. -- Watermark computation takes the maximum `cursorField` value across all - fetched records in the run (not the last record processed), so - out-of-order pagination or concurrent per-page fetching (REQ-002's - optimized parallel mode) cannot regress the watermark. -- Methods added: `computeCursorWatermark()` (private, alongside the - existing `getOriginId()`/`hashObject()` identity helpers). - -### Requirement: Deletion garbage-collection never runs for an incremental sync (REQ-018) - -`synchronizeExternToIntern()` MUST NOT invoke `deleteInvalidObjects()` for -any run whose Synchronization has `syncMode: incremental` — unconditionally, -regardless of that run's fetch-completeness (REQ-009), the computed -deletion ratio (REQ-010), or an explicit `forceDeletion` override. An -incremental fetch is, by construction, a filtered subset of the source; the -absence of a target id from `$synchronizedTargetIds` on an incremental run -is not evidence that the corresponding source record was deleted — it may -simply be outside the cursor filter. `deleteInvalidObjects()` MUST also -independently refuse to run when passed a Synchronization whose `syncMode` -is `incremental`, regardless of caller, so a future caller that invokes it -directly (bypassing `synchronizeExternToIntern()`'s gate) cannot -accidentally delete against a partial incremental fetch. On this refusal, -`deleteInvalidObjects()` MUST log a warning-level message and dispatch a -`SynchronizationDeletionGuardedEvent` with `reason: incremental_mode`, -mirroring its existing `fetch_incomplete`-reason guard (REQ-010), and MUST -return `0`. - -#### Scenario: incremental mode blocks deletion even on a complete fetch - -- GIVEN a Synchronization with `syncMode: incremental`, 100 existing - contracts, and a run whose fetch completes successfully but — because it - is cursor-filtered — returns only 5 changed records -- WHEN `synchronizeExternToIntern()` reaches its cleanup stage -- THEN `deleteInvalidObjects()` is not invoked -- AND 0 objects are deleted -- AND the run's `result.objects.deletionGuard` records - `reason: incremental_mode` - -#### Scenario: forceDeletion cannot override the incremental-mode block - -- GIVEN the same Synchronization as above -- WHEN the run is invoked with `forceDeletion: true` -- THEN deletion is still not invoked — `forceDeletion` only overrides - REQ-010's ratio guard on a `full`-mode run and has no effect on this - unconditional incremental-mode block - -#### Scenario: deleteInvalidObjects() called directly against an incremental Synchronization still refuses - -- GIVEN a Synchronization with `syncMode: incremental` -- WHEN `deleteInvalidObjects()` is invoked directly (not via - `synchronizeExternToIntern()`) with `fetchComplete: true` and - `forceDeletion: true` -- THEN it still returns `0` and deletes nothing -- AND a warning is logged and a `SynchronizationDeletionGuardedEvent` with - `reason: incremental_mode` is dispatched - -#### Scenario: the deleteRestriction single-object delete path is unaffected - -- GIVEN an OpenRegister `ObjectDeletedEvent` triggers a synchronization run - with `mutationType: delete` and `sourceConfig.restrictDeletion: true` - against a Synchronization with `syncMode: incremental` -- WHEN `synchronizeExternToIntern()` runs -- THEN the single-object delete path (`$data !== null && $mutationType === - 'delete'`) is taken — this path never calls `deleteInvalidObjects()`'s - bulk source-diff branch regardless of `syncMode`, so this requirement - introduces no new behavior here; it is called out only to confirm the - event-driven single-object delete is not accidentally caught by this - guard - -**Notes:** - -- This requirement composes with, and takes priority over, REQ-010's - ratio/`forceDeletion` guard: the `syncMode` check happens first and - unconditionally, before REQ-010's `fetchComplete`/ratio logic is ever - reached, for an incremental Synchronization. -- Restoring deletion-based garbage collection for a Synchronization - currently in `incremental` mode requires explicitly switching its - `syncMode` back to `full` — REQ-019's reset-cursor action does not do - this (see REQ-019). -- Methods: `deleteInvalidObjects()` (extended with the new guard clause, - ahead of its existing `fetchComplete === false` early return), - `synchronizeExternToIntern()` (extended call-site check). - -### Requirement: Reset-cursor action clears the stored watermark (REQ-019) - -`SynchronizationsController` MUST expose `POST -/api/synchronizations/{id}/reset-cursor`, which clears the target -Synchronization's `cursorWatermark` (to `null`/absent) and persists that -change, without altering `syncMode` or any other Synchronization field. -This action MUST NOT itself delete, create, or update any target object or -`SynchronizationContract` — it only clears stored cursor state. Following a -reset, the Synchronization's next run resolves `{{ cursor }}` to an empty -string (REQ-016's "no prior watermark" case), which — for a source whose -templated request treats an absent cursor as unfiltered — yields a -full-equivalent fetch that re-evaluates every currently-reachable source -record for create/update via the existing hash-diff contract mechanism -(REQ-003). This action MUST NOT re-enable `deleteInvalidObjects()` for that -Synchronization: REQ-018's guard is keyed on `syncMode`, not on cursor -state, and a reset-cursor call does not change `syncMode`. - -#### Scenario: reset-cursor clears the watermark - -- GIVEN a Synchronization with `syncMode: incremental` and - `cursorWatermark: "2026-07-10T00:00:00Z"` -- WHEN `POST /api/synchronizations/{id}/reset-cursor` is called -- THEN the Synchronization's `cursorWatermark` is persisted as - `null`/absent -- AND `syncMode` remains `incremental`, unchanged - -#### Scenario: the next run after a reset requests an unfiltered fetch - -- GIVEN a Synchronization whose `cursorWatermark` was just cleared via - reset-cursor, with `sourceConfig.endpoint: ".../items?updatedAfter={{ - cursor }}"` -- WHEN the next `synchronize()` run's `getAllObjectsFromApi()` executes -- THEN the rendered endpoint is `.../items?updatedAfter=` (empty cursor - value) - -#### Scenario: reset-cursor does not perform or re-enable deletion - -- GIVEN a Synchronization with `syncMode: incremental` and 100 existing - contracts -- WHEN `POST /api/synchronizations/{id}/reset-cursor` is called, and then - the Synchronization's next run executes and — because the source - honored the empty cursor — refetches all currently-existing source - records -- THEN `reset-cursor` itself deletes nothing -- AND the subsequent run also does not invoke `deleteInvalidObjects()` - (REQ-018 still applies — `syncMode` is still `incremental`) -- AND restoring deletion detection requires a separate, explicit change of - `syncMode` to `full` - -#### Scenario: a missing synchronization id returns 404 - -- GIVEN no Synchronization exists with the given `id` -- WHEN `POST /api/synchronizations/{id}/reset-cursor` is called -- THEN the response is `404`, mirroring the existing `run()`/`test()` - action's not-found handling - -**Notes:** - -- This action follows the existing `activate`/`deactivate`/`run`/`test` - action-route convention on `SynchronizationsController` - (`/api/synchronizations/{id}/`, `POST`). -- **SECURITY:** per REQ-005's existing, pre-existing IDOR note on this - controller, `reset-cursor` inherits the same `@NoAdminRequired` + - `@NoCSRFRequired` + no-per-object-ownership-guard posture as every other - action on `SynchronizationsController` today. This is observed, - pre-existing behavior this change does not alter or worsen (clearing a - watermark is a low-severity action relative to `run`/`test`/`execute` - already available on the same unguarded surface) — flagged for the same - future authorization follow-up already noted under REQ-005, not - addressed here. -- Methods added: `SynchronizationsController::resetCursor()`. - -## MODIFIED Requirements - -### Requirement: Target write, deduplication and file handling (REQ-004) - -The system SHALL write each transformed object to its target, branching to an -OpenRegister-specific write when the target is an OR register/schema, and SHALL -maintain one `SynchronizationContract` per object carrying origin/target ids and -hashes for incremental change detection. The system SHALL cascade contract -creation and id rewrites to sub-objects. It SHALL garbage-collect target objects -no longer present in the source (`deleteInvalidObjects()`) unless `force` opts -out, **and unless the run's fetch was incomplete, the run is a test -(`isTest: true`), the computed deletion ratio exceeds the configured -guard threshold without an explicit `forceDeletion` override (REQ-009, -REQ-010, REQ-011), or the Synchronization's `syncMode` is `incremental` -(REQ-018 — this last guard is unconditional and is never bypassed by -`forceDeletion`)**. The system SHALL fetch, persist, and clean up files -referenced by sync objects: download a file via `CallService`, validate the -target object id is a UUID, persist to storage, optionally run async batch -fetching (ReactPHP), and remove orphaned files/attachments no longer -referenced after a sync. - - - -@e2e exclude backend target-write internals — covered by PHPUnit/Newman, not browser UI - -#### Scenario: OR target write records a contract - -- **GIVEN** a transformed object whose target is an OR register/schema -- **WHEN** `updateTarget()` runs -- **THEN** it delegates to `updateTargetOpenRegister()` and a `SynchronizationContract` records the resulting origin/target ids and hashes. - -#### Scenario: absent source objects are garbage-collected when the fetch was complete, within the deletion-ratio guard, and syncMode is full - -- **GIVEN** a source no longer returns objects that previously had contracts, a complete fetch (REQ-009), a non-test run, a deletion ratio within the configured threshold (REQ-010), and `syncMode` absent or `full` (REQ-018) -- **WHEN** `deleteInvalidObjects()` runs -- **THEN** the now-absent target objects are deleted (garbage-collected). - -#### Scenario: referenced file is fetched and persisted - -- **GIVEN** a sync object referencing a file URL -- **WHEN** `fetchFile()` runs -- **THEN** the file is downloaded via `CallService`, the object id is validated as a UUID before write, and the file is persisted to storage; a null response throws an `Exception`. - -#### Scenario: batch file fetch with cleanup - -- **GIVEN** a batch of file references -- **WHEN** `startAsyncFileFetching()` / `executeAsyncFileFetching()` / `processMultipleFilesWithCleanup()` run -- **THEN** files are fetched concurrently and orphaned files are cleaned up afterward via `cleanupOrphanedFiles()`. - -#### Scenario: unreferenced attachments are removed - -- **GIVEN** a previously-synced object whose attachments are no longer referenced -- **WHEN** `cleanupFilesFromAttachments()` runs -- **THEN** the stale attachments are removed from the object. - -**Notes:** - -- `fetchFile()` builds the request endpoint from source-supplied - `location`/`sourceConfiguration` and substitutes `{{ originId }}` into a - JSON-encoded config. The endpoint is attacker-influenceable via source - configuration; combined with `base64_decode` of the response body this is a - surface worth a dedicated SSRF/content-handling review (flagged, not changed). -- `fetchFileSafely()` wraps `fetchFile()` and swallows exceptions so an async - batch continues past individual file failures — a silent-fail path; failed - fetches are not surfaced to the caller as a structured error. -- `updateTargetOpenRegister()` is the only fully-wired target-write branch; - non-OR targets are handled generically by `writeObjectToTarget()`. -- **See REQ-009/REQ-010/REQ-011/REQ-012/REQ-013 (sync-safety-guardrails) for - the deletion-gating, test-run no-write, ad-hoc Source, and duplicate-contract - detection behaviour layered onto this requirement, and REQ-016/REQ-017/ - REQ-018/REQ-019 (cdc-incremental-sync) for the incremental-mode fetch - filtering, watermark, and unconditional deletion-block layered on top of - those.** -- Methods: `updateTarget()`, `updateTargetOpenRegister()`, - `writeObjectToTarget()`, `deleteInvalidObjects()`, `processSyncContract()`, - `updateContractsForSubObjects()`, `processSynchronizationObject()`, - `writeFile()`, `fetchFile()`, `fetchFileSafely()`, `startAsyncFileFetching()`, - `executeAsyncFileFetching()`, `processMultipleFilesWithCleanup()`, - `cleanupOrphanedFiles()`, `cleanupFilesFromAttachments()`, - `shouldPublishFile()`, `getFileContext()`, `getFilenameFromHeaders()`, - `synchronizeToTarget()`, `detectDuplicateContracts()`. diff --git a/openspec/changes/cdc-incremental-sync/tasks.md b/openspec/changes/cdc-incremental-sync/tasks.md index 0d8da13c1..daf320f97 100644 --- a/openspec/changes/cdc-incremental-sync/tasks.md +++ b/openspec/changes/cdc-incremental-sync/tasks.md @@ -1,151 +1,9 @@ -# Tasks: cdc-incremental-sync - -## Implementation Tasks - -### Task 1: Add `syncMode` and `cursorWatermark` fields to the Synchronization schema -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **files**: `lib/Settings/integriq_register.json` -- **acceptance_criteria**: - - GIVEN the `synchronization` schema WHEN it is inspected THEN it has a - `syncMode` string property (documented values `full`|`incremental`, - default `full`) and a `cursorWatermark` string property, following the - existing `currentPage`/`targetLastSynced` documentation style - - GIVEN the `sourceConfig` property's description THEN it documents the - new recognised keys `cursorField` and `cursorComparator`, alongside the - existing `deletionRatioThreshold`/`resultsPosition`/etc. documentation - - GIVEN an existing Synchronization object with no `syncMode` set WHEN it - is read THEN the application treats it as `full` (no migration/backfill - needed) -- [ ] Implement -- [ ] Test - -### Task 2: Extend Twig request-config templating with a `cursor` context key (REQ-016) -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **files**: `lib/Service/SynchronizationService.php` (`getAllObjectsFromApi()`) -- **acceptance_criteria**: - - GIVEN a Synchronization with `syncMode: incremental` and a templated - `sourceConfig.endpoint` referencing `{{ cursor }}` WHEN - `getAllObjectsFromApi()` runs THEN the rendered endpoint contains the - stored `cursorWatermark` value (or an empty string when unset) - - GIVEN the same Synchronization with a templated `sourceConfig.query` - value referencing `{{ cursor }}` WHEN the fetch runs THEN that query - value is rendered the same way endpoint values already are - - GIVEN a Synchronization with `syncMode` absent or `full` WHEN the fetch - runs THEN the Twig context has no `cursor` key and `sourceConfig.query` - values are passed through unrendered — byte-identical to current - behavior (regression check) -- [ ] Implement -- [ ] Test - -### Task 3: Compute and persist the cursor watermark, gated on fetch-completeness (REQ-017) -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **files**: `lib/Service/SynchronizationService.php` (new private - `computeCursorWatermark()`; `synchronizeExternToIntern()` Stage 5/end-of-run) -- **acceptance_criteria**: - - GIVEN an incremental run whose fetch completes (REQ-009 - `fetchInfo.complete === true`) WHEN the run finishes THEN - `cursorWatermark` is persisted as the maximum `sourceConfig.cursorField` - value seen across the fetched records - - GIVEN an incremental run whose fetch is marked incomplete (page - failure, rate-limit, or safety-cap per REQ-009) WHEN the run finishes - THEN `cursorWatermark` is left unchanged - - GIVEN an incremental `isTest: true` run whose fetch completes WHEN the - run finishes THEN `cursorWatermark` is left unchanged (REQ-011 parity) - - GIVEN a fetched record whose configured `cursorField` resolves to - `null` WHEN the run processes it THEN an `Exception` is thrown naming - the missing field, and no partial/incorrect watermark is persisted -- [ ] Implement -- [ ] Test - -### Task 4: Hard-block `deleteInvalidObjects()` for incremental synchronizations (REQ-018) -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-deletion-garbage-collection-never-runs-for-an-incremental-sync-req-018` -- **files**: `lib/Service/SynchronizationService.php` - (`synchronizeExternToIntern()` Stage 5 call site; `deleteInvalidObjects()`) -- **acceptance_criteria**: - - GIVEN a Synchronization with `syncMode: incremental` WHEN - `synchronizeExternToIntern()` reaches its cleanup stage THEN - `deleteInvalidObjects()` is never invoked, and `result.objects. - deletionGuard.reason` is `incremental_mode` - - GIVEN the same Synchronization and `forceDeletion: true` WHEN the run - executes THEN deletion is still blocked (unconditional — `forceDeletion` - has no effect on this guard) - - GIVEN `deleteInvalidObjects()` is invoked directly (bypassing - `synchronizeExternToIntern()`) against a Synchronization with `syncMode: - incremental` WHEN it runs THEN it returns `0`, logs a warning, and - dispatches `SynchronizationDeletionGuardedEvent` with `reason: - incremental_mode` - - GIVEN the event-driven single-object `deleteRestriction` path (REQ-010) - on an incremental Synchronization WHEN an `ObjectDeletedEvent` fires - THEN the single-object delete still runs unaffected (regression check — - this path never calls the bulk-diff branch this task guards) -- [ ] Implement -- [ ] Test - -### Task 5: Reset-cursor controller action and route (REQ-019) -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **files**: `lib/Controller/SynchronizationsController.php`, - `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN a Synchronization with a stored `cursorWatermark` WHEN `POST - /api/synchronizations/{id}/reset-cursor` is called THEN the watermark - is persisted as cleared and `syncMode` is unchanged - - GIVEN no Synchronization exists with the given id WHEN the action is - called THEN it responds `404`, matching `run()`/`test()`'s existing - not-found handling - - GIVEN a successful reset WHEN the response is inspected THEN it - reflects the cleared watermark (for SPA confirmation feedback) -- [ ] Implement -- [ ] Test - -### Task 6: Synchronization SPA — sync mode field + reset-cursor action -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **files**: Synchronization edit form component (e.g. - `src/modals/Synchronization/EditSynchronization.vue` or equivalent — match - existing modal location), Synchronization detail/actions view -- **acceptance_criteria**: - - GIVEN the Synchronization edit form WHEN an admin opens it THEN a "Sync - mode" field (full / incremental) and, when incremental, a "Cursor - field" + "Cursor comparator" configuration are shown - - GIVEN an incremental Synchronization's detail/actions view WHEN an - admin opens it THEN a "Reset cursor" action button is available, - labelled/tooltipped to make clear it clears the watermark only and does - **not** delete data or restore deletion detection (design.md Decision - 3 / Risks) - - GIVEN the "Reset cursor" action WHEN clicked THEN it calls `POST - .../reset-cursor` and shows a confirmation -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/`) — - watermark advance/no-advance (Task 3), incremental deletion block - (Task 4), cursor templating (Task 2) -- [ ] Newman/Postman tests for new/changed API endpoints — `reset-cursor` - (Task 5) -- [ ] Browser tests (Playwright MCP) for UI changes — sync mode field + - reset-cursor action (Task 6) -- [ ] Integration test: two successive incremental runs against a - synthetic paginated source fetch/write only the delta between them - (proposal.md Scope item 5) -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` — incremental sync mode, - cursor field configuration, reset-cursor action, and the explicit - "incremental mode never deletes" caveat -- [ ] Screenshot captured and committed to `docs/images/` — Synchronization - edit form's new Sync mode field - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for: - "Sync mode", "Cursor field", "Cursor comparator", "Reset cursor" action - label + confirmation + tooltip text +# Tasks: cdc-incremental-sync (superseded) + +The original 24-checkbox list was removed with the 2026-09-02 retirement +(see proposal.md for the disposition; the list survives in +`archive/2026-07-15-cdc-incremental-sync/tasks.md`, where 16/24 boxes are +checked with per-task evidence, and in git history). The residual +live-instance verification and docs work is listed there with per-box +reasons and belongs to a verification-pack-style follow-up. There is +nothing to implement from this change directly. diff --git a/openspec/changes/cdc-incremental-sync/test-plan.md b/openspec/changes/cdc-incremental-sync/test-plan.md deleted file mode 100644 index a5fdc4941..000000000 --- a/openspec/changes/cdc-incremental-sync/test-plan.md +++ /dev/null @@ -1,224 +0,0 @@ -# Test Plan: cdc-incremental-sync - -## Test Cases - -### TC-1: incremental run injects stored watermark into a templated endpoint -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **type**: api -- **persona**: N/A (backend engine behavior) -- **preconditions**: Synchronization with `syncMode: incremental`, - `sourceConfig.endpoint: ".../items?updatedAfter={{ cursor }}"`, - `cursorWatermark: "2026-07-01T00:00:00Z"` -- **steps**: trigger a run (`POST /api/synchronizations/{id}/run`) against - a mocked/stub source that echoes the requested URL -- **expected result**: the source receives a request to - `.../items?updatedAfter=2026-07-01T00:00:00Z` -- **test command**: `/test-api` (PHPUnit unit test on - `getAllObjectsFromApi()` is the primary coverage; Newman covers the - outer `run` endpoint contract) - -### TC-2: incremental run injects stored watermark into a templated query parameter -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **type**: api -- **preconditions**: Synchronization with `syncMode: incremental`, - `sourceConfig.query.updatedAfter: "{{ cursor }}"`, `cursorWatermark: "42"` -- **steps**: trigger a run against a mocked source capturing outbound query - parameters -- **expected result**: outbound `updatedAfter` query parameter equals `"42"` -- **test command**: `/test-api` - -### TC-3: full-mode run is unaffected by the cursor templating extension -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **type**: regression -- **preconditions**: Synchronization with `syncMode` absent (pre-existing - fixture, unmodified) -- **steps**: trigger a run -- **expected result**: request endpoint/query are byte-identical to - pre-change behavior; no `cursor` context key present -- **test command**: `/test-regression` - -### TC-4: watermark advances after a complete fetch -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental`, - `sourceConfig.cursorField: "updatedAt"`, source returns records with - `updatedAt` up to `2026-07-15T09:00:00Z`, fetch completes normally -- **steps**: trigger a run; inspect persisted Synchronization afterward -- **expected result**: `cursorWatermark === "2026-07-15T09:00:00Z"` -- **test command**: `/test-functional` - -### TC-5: watermark does not advance after a page failure mid-fetch -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental` and - existing `cursorWatermark: "2026-07-01T00:00:00Z"`; mocked source returns - HTTP 500 on page 2 of 3 -- **steps**: trigger a run -- **expected result**: fetch marked incomplete (REQ-009); `cursorWatermark` - unchanged at `"2026-07-01T00:00:00Z"` after the run -- **test command**: `/test-functional` - -### TC-6: watermark does not advance after a 429 rate-limit -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental`; source - returns HTTP 429 on first page -- **steps**: trigger a run -- **expected result**: `TooManyRequestsHttpException` (429) thrown to - caller; `cursorWatermark` unchanged -- **test command**: `/test-functional` - -### TC-7: watermark does not advance for a test run -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental` -- **steps**: `POST .../synchronizations/{id}/test`, fetch completes - successfully -- **expected result**: `cursorWatermark` unchanged (REQ-011 parity) -- **test command**: `/test-functional` - -### TC-8: missing cursorField throws rather than computing a wrong watermark -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-cursor-watermark-advances-only-after-a-complete-successful-fetch-req-017` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental`, - `sourceConfig.cursorField: "updatedAt"`; one fetched record has no - `updatedAt` value -- **steps**: trigger a run -- **expected result**: `Exception` thrown naming the missing field; no - `cursorWatermark` change persisted -- **test command**: `/test-functional` - -### TC-9: incremental mode blocks deletion even on a complete fetch -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-deletion-garbage-collection-never-runs-for-an-incremental-sync-req-018` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental`, 100 - existing contracts; a complete incremental fetch returns 5 changed - records (cursor-filtered, so 95 are absent from this run by design) -- **steps**: trigger a run -- **expected result**: `deleteInvalidObjects()` not invoked; 0 objects - deleted; `result.objects.deletionGuard.reason === "incremental_mode"` -- **test command**: `/test-functional` - -### TC-10: forceDeletion cannot override the incremental-mode block -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-deletion-garbage-collection-never-runs-for-an-incremental-sync-req-018` -- **type**: functional -- **preconditions**: same as TC-9 -- **steps**: trigger a run with `forceDeletion: true` -- **expected result**: deletion still blocked; 0 objects deleted -- **test command**: `/test-functional` - -### TC-11: deleteInvalidObjects() called directly still refuses on incremental -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-deletion-garbage-collection-never-runs-for-an-incremental-sync-req-018` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental` -- **steps**: call `deleteInvalidObjects()` directly with - `fetchComplete: true, forceDeletion: true` -- **expected result**: returns `0`; warning logged; - `SynchronizationDeletionGuardedEvent` dispatched with - `reason: incremental_mode` -- **test command**: `/test-functional` (PHPUnit-level; exercised via a - direct service-layer test, not browser) - -### TC-12: event-driven single-object delete path unaffected on incremental -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-deletion-garbage-collection-never-runs-for-an-incremental-sync-req-018` -- **type**: regression -- **preconditions**: Synchronization with `syncMode: incremental`, - `sourceConfig.restrictDeletion: true` -- **steps**: fire an OpenRegister `ObjectDeletedEvent` for a synced object -- **expected result**: the single matching target object is deleted, - unaffected by REQ-018 (this path never reaches the bulk-diff branch) -- **test command**: `/test-regression` - -### TC-13: reset-cursor clears the watermark without touching syncMode -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **type**: api -- **preconditions**: Synchronization with `syncMode: incremental`, - `cursorWatermark: "2026-07-10T00:00:00Z"` -- **steps**: `POST /api/synchronizations/{id}/reset-cursor` -- **expected result**: `200`; persisted `cursorWatermark` is null/absent; - `syncMode` still `incremental` -- **test command**: `/test-api` - -### TC-14: next run after reset requests an unfiltered fetch -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **type**: functional -- **preconditions**: watermark just cleared via TC-13, - `sourceConfig.endpoint: ".../items?updatedAfter={{ cursor }}"` -- **steps**: trigger the next run -- **expected result**: rendered endpoint is `.../items?updatedAfter=` - (empty cursor) -- **test command**: `/test-functional` - -### TC-15: reset-cursor does not perform or re-enable deletion -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental`, 100 - existing contracts -- **steps**: `POST .../reset-cursor`, then trigger the next run (source - honors empty cursor and returns its full set) -- **expected result**: reset-cursor itself deletes nothing; the subsequent - run also does not invoke `deleteInvalidObjects()` (REQ-018 still applies) -- **test command**: `/test-functional` - -### TC-16: reset-cursor against a missing synchronization returns 404 -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **type**: api -- **preconditions**: no Synchronization with the given id -- **steps**: `POST /api/synchronizations/{bogus-id}/reset-cursor` -- **expected result**: `404` -- **test command**: `/test-api` - -### TC-17: two successive incremental runs fetch/write only the delta (integration) -- **spec_ref**: proposal.md Scope item 5 / `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **type**: functional -- **preconditions**: Synchronization with `syncMode: incremental` against a - synthetic paginated source with a mutable dataset -- **steps**: run 1 fetches/writes the full initial dataset and advances the - watermark; mutate the source (add N new/changed records with newer - `updatedAt`); run 2 executes -- **expected result**: run 2's fetch request is cursor-filtered to the new - watermark; only the N new/changed records are fetched and written; - contracts for the unrelated, unchanged records are untouched -- **test command**: `/test-functional` - -### TC-18: sync mode field and reset-cursor action render in the SPA -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-incremental-sync-mode-selects-a-cursor-filtered-fetch-request-req-016` -- **type**: functional -- **preconditions**: authenticated admin on the Synchronizations page -- **steps**: open the Synchronization edit form; select `incremental`; - configure a cursor field; save; open the detail/actions view -- **expected result**: the "Sync mode"/"Cursor field" fields persist - correctly; a "Reset cursor" action is visible with clarifying help text - for an incremental Synchronization -- **test command**: `/test-functional` - -### TC-19: reset-cursor action is reachable and labeled clearly for a non-technical operator -- **spec_ref**: `openspec/changes/cdc-incremental-sync/specs/synchronization-engine/spec.md#requirement-reset-cursor-action-clears-the-stored-watermark-req-019` -- **type**: persona -- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) — needs to - understand that reset-cursor does not delete data and does not restore - deletion detection, per design.md's explicit caveat -- **preconditions**: incremental Synchronization configured -- **steps**: locate and read the reset-cursor action's tooltip/help text -- **expected result**: the text makes clear (a) only the cursor is cleared, - (b) no data is deleted by this action, and (c) deletion detection stays - off until `syncMode` is explicitly switched to `full` -- **test command**: `/test-persona-noor` - -## Coverage Summary - -| Requirement | Covered by | -|---|---| -| REQ-016 (cursor-filtered fetch request) | TC-1, TC-2, TC-3, TC-18 | -| REQ-017 (watermark advance gating) | TC-4, TC-5, TC-6, TC-7, TC-8 | -| REQ-018 (deletion hard-blocked in incremental mode) | TC-9, TC-10, TC-11, TC-12 | -| REQ-019 (reset-cursor action) | TC-13, TC-14, TC-15, TC-16, TC-19 | -| REQ-004 (MODIFIED — deletion gate composition) | TC-9, TC-10, TC-12, TC-15 | -| Integration (two-run delta-only behavior) | TC-17 | - -## Out of Scope -- Log-based CDC / binlog tailing — no DB-source adapter exists to test - against (proposal.md Out of Scope); no test cases written. -- Automatic cursor-field inference — not implemented, nothing to test. -- Sub-run (per-page) watermark checkpointing — deliberately not - implemented (design.md Non-Goals); no test cases written. diff --git a/openspec/changes/connector-catalog-ui/context-brief.md b/openspec/changes/connector-catalog-ui/context-brief.md deleted file mode 100644 index c37bdc202..000000000 --- a/openspec/changes/connector-catalog-ui/context-brief.md +++ /dev/null @@ -1,24 +0,0 @@ -# Context Brief: connector-catalog-ui -Source: Specter deep-research 2026-07-14 (insights #1256, #1265). VERIFY every code claim against HEAD before writing artifacts. - -## Problem -Discovery and day-2 ops are API-only. Seeded connectors (PDOK etc.) sit dormant behind feature flags with no browsable surface; configuration export/import (OpenAPI JSON, slug translation, credential redaction via ConfigurationHandlers) exists with NO UI. Every competitor leads with a catalog/template gallery (n8n 600+ templates; Workato tens of thousands of recipes) — the #1 onboarding device. - -## Current state (verify at HEAD) -- Configuration groups bundling sources/endpoints/mappings/rules/jobs/syncs; export/import API endpoints (find exact routes in appinfo/routes.php + ConfigurationController). -- Seeds: PDOK sources behind pdok.feature_flag; in-flight seed changes (BRP, KVK, xWiki, messaging) will add more. -- UI: manifest v2, 26 pages; src/manifest.json; FeaturesRoadmap page exists (look at its pattern for a catalog-like page). -- Source types enum: json/xml/soap/ftp/sftp (+rest/wms/wfs seeded). - -## In scope -1. Catalog page (new manifest page "Catalog"): browsable cards of (a) connector types/adapters available (from a registry of adapter metadata: name, category, standards, status incl. feature-flagged/dormant), (b) seeded source templates, (c) importable configuration templates. Search + category filter. Detail modal with description + "Enable"/"Instantiate" action (creates the Source/Configuration from seed, respecting feature flags + action matrix authorization). -2. Configuration import/export UI: export a configuration group to file (redacted) from the UI; import with preview (what will be created/updated, slug collisions) + confirmation; surface redacted-credential placeholders needing re-entry after import. -3. Adapter metadata registry: PHP-side registry (attribute or service-based) describing each built-in adapter/connector for the catalog — single source, no hardcoded frontend list. -4. Tests: PHP unit for registry + import preview; vitest for catalog store; Playwright e2e for catalog browse + import flow (e2e-coverage gate). -## Out of scope -- Full environments/promotion with credential re-binding (deferred until source-broker-credentials lands). -- Community template marketplace (remote fetch) — local/seeded only. - -## Constraints -- Use nc-vue Cn* primitives (CnIndexPage/CnDataTable/cards) — NO nc-vue library changes; follow manifest-v2 typed pages where possible (#814 wants LESS custom pages, so prefer typed primitives; custom page only if unavoidable — mind hydra custom-widget-ratchet gate). -- Specs: new capability spec connector-catalog; delta to configuration-export-import (UI scenarios). diff --git a/openspec/changes/connector-catalog-ui/discovery.md b/openspec/changes/connector-catalog-ui/discovery.md deleted file mode 100644 index 880e7a0c1..000000000 --- a/openspec/changes/connector-catalog-ui/discovery.md +++ /dev/null @@ -1,39 +0,0 @@ -# Discovery: connector-catalog-ui - -## Question - -Three open feasibility questions from the context brief needed resolving before specs/design could be written with confidence: -1. Can the Catalog page be built from an existing manifest-v2 typed primitive (no `nextcloud-vue` change), or is a bespoke `type: "custom"` page unavoidable? -2. Does an adapter/connector metadata registry already exist in Integriq or OpenRegister that this change should extend rather than duplicate? -3. Is configuration export/import really "API-only" today (implying a route to wrap a UI around), or something else? - -## Approach Taken - -- Read `src/manifest.json` in full (26 pages) and traced `FeaturesRoadmap`'s `type: "roadmap"` through `src/main.js` / `src/registry.js` to confirm it is a library-supplied typed primitive, not a bespoke component — precedent that typed primitives beyond plain CRUD exist. -- Searched sibling `apps-extra` repos' `src/manifest.json` for `"cards"`/`"gallery"`/`"filters"` usage and found two live precedents: `openbuild`'s `VirtualApps` page (`type: "index"`, `viewMode: "cards"`, `cardComponent: "ApplicationCard"`) and `softwarecatalog`'s `Organisaties` page (same pattern, `cardComponent: "OrganisatieCard"`). Also found `openbuild`'s `Templates` page, which uses a genuinely bespoke `type: "custom"` component (`TemplateGallery`) — but only because it integrates a *remote* template-store search, a capability explicitly out of scope here. -- Read `@conduction/nextcloud-vue`'s `CnIndexPage.vue` source directly (checked out at `/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/nextcloud-vue`) and confirmed `viewMode`/`viewModes`, `cardComponent`, `filters` (facet chips), and search are all config-driven props on the existing component — no library change needed to get a searchable, filterable card grid. -- Grepped `lib/AppInfo/Application.php` for `IntegrationRegistry`/`addProvider` and read `lib/Service/Adapter/AbstractCategoryAdapterProvider.php` to establish what registry machinery already exists. -- Grepped `appinfo/routes.php` for `configuration`/`Configuration` and read the "Import & Export" comment block plus `lib/Service/ConfigurationService.php` and its callers (via `tests/Unit/Service/ConfigurationServiceTest.php`, the only caller found) to establish the real current reachability of export/import. - -## Findings - -1. **Page type**: `type: "index"` + `config.viewMode: "cards"` + `config.cardComponent` + `config.filters: [...]` is an established, twice-shipped, config-only pattern for a browsable, filterable card catalog backed by an OpenRegister register/schema. It satisfies the "prefer typed primitives over custom pages" constraint directly — no `nextcloud-vue` change, no custom-widget-ratchet gate exposure. -2. **Adapter registry**: A registry already exists but is narrower than the brief assumed — `OCA\OpenRegister\Service\Integration\IntegrationRegistry` (OR-side) plus Integriq's `AbstractCategoryAdapterProvider` covers exactly 4 adapters (Azure Virtual Desktop, SharePoint Online, Microsoft 365, S3), registered by hand in `Application.php::registerIntegrationProviders()`. PDOK, Digikoppeling, Berichtenbox, DSO, and the `register.d`-seeded sources (BRP/KVK/xWiki/messaging/OpenCorporates) are **not** in this registry. Two seeding mechanisms exist and are not interchangeable: container-level `*.feature_flag` app-config (PDOK, Berichtenbox) vs. per-object `configuration.mock`/`isEnabled` on seeded Source objects (everything else). -3. **Configuration export/import**: There is no `ConfigurationController` and no route — `ConfigurationService::exportConfiguration()`/`importConfiguration()` are called only from PHPUnit tests today. The brief's framing ("API-only") is inaccurate; the correct framing is "fully implemented, fully tested, entirely unrouted." OpenRegister's generic `/api/registers/{id}/export` / `/api/configurations/{id}/import` endpoints (mentioned in the routes.php dead-code comment) operate at register granularity, not at Integriq's configuration-group granularity (`configurations[]` membership spanning 6 entity types) — they are not a drop-in substitute. -4. Source "type" has no enforced enum (contra the brief); the live vocabulary is `lib/Settings/integriq_register.json`'s free-form `type` field with recognised values `api, database, file, soap, dso, peppol, psd2, sms, payment`. -5. **Action-level authorization (ADR-023) already fully implemented in Integriq** (correction of an earlier draft that assumed it was absent): `lib/Service/ActionAuthService.php` (`requireAction()`/`can()` over an `IAppConfig` matrix, admin break-glass pass), `lib/Controller/ActionMatrixController.php` (admin matrix editor), `lib/Repair/InitializeActions.php`, and `lib/actions.seed.json` (38 actions, `.` convention — `source.test`, `job.run`, `pdok.suggest`) — already consumed by SourcesController, MappingsController, EventsController, JobsController and others. This change only appends three action keys to the existing seed; no new auth machinery. - -## Recommendation - -- **Catalog page**: build with `type: "index"` + `viewMode: "cards"`, backed by a new `catalog_item` register/schema. Do not request a new `nextcloud-vue` typed primitive and do not write a `type: "custom"` page — the cards pattern is proven and sufficient. -- **Adapter registry**: do not extend `IntegrationRegistry` in this change. Its `IntegrationProvider` interface is shaped for the 4 category adapters (auth requirements, storage strategy, health) and promoting PDOK/Digikoppeling/Berichtenbox/DSO into it is a larger, separate refactor with its own risk surface. Instead, build a lightweight Integriq-local `CatalogRegistryService` that (a) reads the 4 already-registered providers from `IntegrationRegistry` for their metadata, (b) hand-describes PDOK/Digikoppeling/Berichtenbox/DSO in a small static descriptor list colocated with each adapter's namespace, and (c) reads the `register.d/*-source.json` seed fragments for seeded-source templates. This is additive and reuses rather than duplicates; promoting (b) into full `IntegrationProvider`s is recorded as a follow-up, not done here. -- **Configuration import/export UI**: resurrect a thin `ConfigurationController` in Integriq wrapping the existing `ConfigurationService` unchanged, rather than building against OR's generic endpoints. This preserves Integriq's configuration-group semantics and reuses fully-tested logic; it does not reopen review of the underlying redaction/slug-translation behaviour (documented as retrofit-accurate in `configuration-export-import/spec.md` REQ-001–REQ-005), only adds a route + UI layer on top. - -## Risks Uncovered - -- The `ConfigurationController` import endpoint becomes a new privileged write surface (creates/updates Source/Endpoint/Mapping/Rule/Job/Synchronization objects from an uploaded, largely unvalidated OAS document — REQ-003 Notes: "Import performs no schema validation of the per-entity payload beyond the top-level `components` check"). Gated via the existing `ActionAuthService` with a `configuration.import` action seeded `["admin"]` (finding 5), matching the existing `99-source-lockdown.json` admin-only lock on the `source` schema. -- `catalog_item` materialisation (repair step) must not race with `register.d` fragment application at boot, since it reads seed fragment files as one of its inputs. - -## Next Steps - -Proceed to specs and design with the three decisions above locked in. diff --git a/openspec/changes/connector-catalog-ui/migration.md b/openspec/changes/connector-catalog-ui/migration.md deleted file mode 100644 index ca6041377..000000000 --- a/openspec/changes/connector-catalog-ui/migration.md +++ /dev/null @@ -1,51 +0,0 @@ -# Migration: connector-catalog-ui - -## Current State - -No `catalog_item` schema exists. `lib/Settings/integriq_register.json` + `lib/Settings/register.d/*.json` fragments define the `openconnector` register's other 15 schemas (source, endpoint, mapping, rule, job, synchronization, consumer, event, event_subscription, event_message, call_log, job_log, synchronization_log, synchronization_contract — see `openconnector-storage-migration` spec). Fragments are merged and imported via `OCA\OpenRegister\Service\ConfigurationService::importFromApp()`, invoked both from `lib/Repair\InitializeRegister` (repeatable repair step, runs on every `occ upgrade` and app enable) and, historically, one-shot from `lib/Migration\Version2Date20260520000001` for the chain-B storage cutover. There is no `catalog_item` register/schema, and no repair step materialises catalog data from the registries described in design.md. - -## Target State - -- A new schema fragment `lib/Settings/register.d/catalog-item-schema.json` defines `catalog_item` (fields: `name`, `description`, `category`, `kind`, `mechanism`, `flagKey`, `sourceTemplateSlug`, `standards[]`, `icon`) alongside the existing 15 schemas, merged by the existing `InitializeRegister` fragment-merge mechanism — **no new migration class is needed for the schema itself**, since `register.d/*.json` fragments are picked up automatically by the existing repair step on every run (same mechanism as the `99-source-lockdown.json` and `brp-haalcentraal-source.json` fragments already in the repo). -- A new repair step `lib/Repair/MaterializeCatalogItems.php` (implementing `\OCP\Migration\IRepairStep`, registered in `lib/AppInfo/Application.php` alongside `InitializeRegister`) runs `CatalogRegistryService::materialize()` on every `occ upgrade` / app enable, upserting one `catalog_item` OpenRegister object per real adapter/seed-source entry (see design.md Decisions), keyed by a stable `kind:slug` identifier so re-runs update in place. -- Three new action keys — `catalog.instantiate`, `configuration.export`, `configuration.import` — are appended to the **existing** `lib/actions.seed.json` (ADR-023 matrix seed, verified present at HEAD with 38 actions in `.` style, e.g. `source.test`, `job.run`, `pdok.suggest`), defaulting to `["admin"]`, applied by the existing `lib/Repair/InitializeActions.php` repair step. No new auth service, controller, or repair step is needed for authorization. - -## Migration Class - -No native-table `lib/Migration/VersionXXXXXXXXXX.php` schema migration is required — `catalog_item` is an OpenRegister-managed schema (JSON fragment + repeatable repair step), not a native Doctrine/QBMapper table, matching the pattern already used for every other Integriq entity (`openconnector-direct-or-usage`). If a one-shot trigger is later found necessary (e.g. to force an immediate materialisation on upgrade rather than waiting for the next repair-step pass), it would follow the `Version2Date20260520000001` pattern exactly: `preSchemaChange()` no-op, `changeSchema()` returns `null`, `postSchemaChange()` resolves `CatalogRegistryService` from the container and calls `materialize()` idempotently, guarded the same way (`class_exists` check for OpenRegister availability, try/catch around service resolution). This is deferred to the apply step's judgment — the repair step alone is expected to be sufficient since it already runs on every upgrade. - -``` -Version: (none required — see above) -File: lib/Repair/MaterializeCatalogItems.php (repair step, not a versioned migration) -Key operations: -- Read IntegrationRegistry-registered providers (4 category adapters) -- Read static descriptor list (PDOK, Digikoppeling, Berichtenbox, DSO) -- Read register.d/*-source.json seed fragments (BRP, KVK, xWiki, messaging, OpenCorporates, PDOK) -- Upsert one catalog_item object per entry, keyed by kind:slug -``` - -## Migration Steps - -1. Ship `lib/Settings/register.d/catalog-item-schema.json` — picked up automatically by the existing `InitializeRegister` repair step's fragment merge on the next `occ upgrade` or app enable. Verifiable: `catalog_item` appears as a schema under the `openconnector` register. -2. Ship `lib/Repair/MaterializeCatalogItems.php`, registered as an `IRepairStep` in `Application.php`. Verifiable: repair step name appears in `occ upgrade` output. -3. First repair-step run materialises `catalog_item` objects for every real adapter/seed source found (see design.md Seed Data — these are not fictional, they mirror already-shipped code). Verifiable: `GET /apps/openregister/api/objects/integriq/catalog_item` returns one object per entry. -4. Append `catalog.instantiate`, `configuration.export`, `configuration.import` (each `["admin"]`) to the existing `lib/actions.seed.json`; the existing `InitializeActions` repair step applies them on the next run. Verifiable: the existing admin action-matrix settings panel (`ActionMatrixController`) lists the three new actions. -5. Re-running steps 1–4 (idempotency check) produces no duplicate `catalog_item` objects and no duplicate action-matrix entries. - -## Data Impact - -- Additive only: creates new `catalog_item` objects (expected count: ~4 category adapters + 4 hand-described adapters [PDOK, Digikoppeling, Berichtenbox, DSO] + ~6 seeded source templates [PDOK, BRP, KVK, xWiki, messaging (grouped or per-channel), OpenCorporates] ≈ 12-16 objects at initial rollout). No existing Source/Endpoint/Mapping/Rule/Job/Synchronization/Consumer object is read, modified, or deleted by this migration. -- Safe on live data: the repair step only writes to the new `catalog_item` schema; it performs read-only queries against `IntegrationRegistry` and the existing seed fragments. -- No downtime: repair steps run as part of the normal `occ upgrade` flow, same as `InitializeRegister` today. - -## Rollback Procedure - -Remove `lib/Settings/register.d/catalog-item-schema.json`, `lib/Repair/MaterializeCatalogItems.php`, and its registration in `Application.php`. The `catalog_item` schema and its objects become orphaned (no longer written to) but are not automatically deleted — an operator MAY run `occ openregister:schema:delete openconnector catalog_item` (existing OpenRegister command) to remove them if a clean rollback is required. No other schema or object is touched, so rollback carries zero risk to existing Source/Endpoint/Configuration data (matches proposal.md Rollback Strategy). - -## Validation - -- `occ upgrade` completes without error; log output shows the `MaterializeCatalogItems` repair step ran. -- `GET /apps/openregister/api/objects/integriq/catalog_item` returns the expected object count (~12-16) with no duplicates. -- Re-running `occ upgrade` a second time produces the same object count (idempotency). -- The Catalog page (`/catalog`) renders all materialised items as cards without error. -- The admin action-matrix settings panel shows `catalog.instantiate`, `configuration.export`, `configuration.import` all defaulted to `["admin"]`. diff --git a/openspec/changes/connector-catalog-ui/proposal.md b/openspec/changes/connector-catalog-ui/proposal.md index cbeba1f7c..374cbce95 100644 --- a/openspec/changes/connector-catalog-ui/proposal.md +++ b/openspec/changes/connector-catalog-ui/proposal.md @@ -1,71 +1,46 @@ -# Proposal: connector-catalog-ui - -## Summary - -Integriq today has real integration capability — seeded PDOK/BRP/KVK/xWiki/messaging sources, a working (but unrouted) configuration export/import service with slug-translation and credential redaction, and four registered category adapters (Azure Virtual Desktop, SharePoint Online, Microsoft 365, S3) — but none of it is discoverable from the UI. Onboarding is 100% tribal-knowledge/API-only. This change adds a browsable **Catalog** page (connector adapters, seeded source templates, importable configuration templates, with search + category filter and an Enable/Instantiate action) and a **Configuration import/export UI** (export-to-file with redaction, import-with-preview and confirmation, redacted-credential re-entry flagging), built entirely from existing manifest-v2 typed primitives (`CnIndexPage` with `viewMode: "cards"`, matching the precedent already shipped in `openbuild`'s `VirtualApps` page and `softwarecatalog`'s `Organisaties` page) — no `nextcloud-vue` library changes. - -## Motivation - -Every competitor in this space leads with a template/catalog gallery as the #1 onboarding device (n8n: 600+ templates; Workato: tens of thousands of recipes). Integriq's seeded sources (PDOK behind `pdok.feature_flag`; BRP/KVK/xWiki/messaging/OpenCorporates seeded via `lib/Settings/register.d/*.json` in mock mode) sit dormant with no surface for an operator to find, understand, or enable them. Separately, `ConfigurationService` (`lib/Service/ConfigurationService.php`) already implements a complete, tested export/import/redaction/slug-translation pipeline (`openspec/specs/configuration-export-import/spec.md`, retrofit, status `done`) but has **no controller, no route, and no UI** — it is reachable only from PHPUnit tests. This is the highest-leverage, lowest-net-new-code opportunity in the app: surface what already exists rather than build new integration logic. - -## Affected Projects - -- [x] Project: `integriq` — new Catalog page (manifest + PHP catalog API + adapter metadata registry), new Configuration import/export UI (manifest + a thin `ConfigurationController` wrapping the existing `ConfigurationService`), new `catalog_item` register schema seeded by a repair step. - -## Scope - -### In Scope - -1. **Catalog page** (`src/manifest.json` page `id: "Catalog"`, `type: "index"`, `viewMode: "cards"`) browsing three kinds of catalog items — built-in connector adapters (PDOK, Berichtenbox, Digikoppeling, the four `IntegrationProvider` category adapters, DSO), seeded source templates (BRP/KVK/xWiki/messaging/OpenCorporates/PDOK), and importable configuration templates — each with category, status (`available` / `dormant` behind a feature flag), and a detail modal offering an "Enable" (flip `*.feature_flag` app-config) or "Instantiate" (create a Source/Configuration from a seed) action, gated by the existing ADR-023 authorization matrix and respecting feature-flag state. -2. **Adapter metadata registry** (PHP-side, single source of truth) — a new `CatalogRegistryService` in Integriq that assembles catalog entries from (a) the existing OR-side `IntegrationRegistry` for the 4 registered category adapters, (b) a small hand-written descriptor list for PDOK/Digikoppeling/Berichtenbox/DSO (not currently in any registry), and (c) the `register.d/*.json` seeded-source fragments, materialised into `catalog_item` OpenRegister objects by a repair step so the Catalog page can be a standard register/schema-backed `index` page. -3. **Configuration import/export UI** — a thin `ConfigurationController` (new route group) wrapping the existing, already-tested `ConfigurationService::exportConfiguration()` / `importConfiguration()`; export produces a redacted download; import shows a preview (creates vs. updates vs. slug collisions) and requires explicit confirmation before writing; imported Sources with redacted credential placeholders are flagged for operator re-entry. -4. **Tests**: PHPUnit for the catalog registry and import-preview diff logic; vitest for the catalog Pinia store; Playwright e2e for catalog-browse and the import flow (satisfies the `e2e-coverage` hydra gate). -5. **Specs**: new capability spec `connector-catalog`; delta to `configuration-export-import` adding the UI-facing scenarios (export-from-UI, import-preview, confirmation, redacted-credential flagging); delta to `openconnector-app-manifest` adding the `Catalog` page and menu entry (scoped to this addition — the base manifest spec is already stale against `src/manifest.json` at HEAD on unrelated axes; this change does not attempt a full resync). - -### Out of Scope - -- Full environments/promotion with credential re-binding across environments — deferred until `source-broker-credentials` lands (per context brief). -- A community/remote template marketplace (openbuild's `TemplateGallery` pattern of a remote registry search) — catalog entries are local/seeded only. -- Retrofitting the existing drift in `openconnector-app-manifest` spec (stale page count, phantom `Import` page, flat-vs-grouped menu) beyond the one addition this change makes. -- Registering PDOK/Digikoppeling/Berichtenbox/DSO into the OR `IntegrationRegistry` as first-class `IntegrationProvider`s — they are catalogued via a lighter descriptor list in this change; promoting them to full `IntegrationProvider`s is a separate follow-up (see design.md). - -## Approach - -Reuse the manifest-v2 `index` page type in `viewMode: "cards"` (the same pattern already shipped by `openbuild`'s `VirtualApps` page and `softwarecatalog`'s `Organisaties` page — a `cardComponent` override, `filters: [...]` for category/status facets, all config-only, zero `nextcloud-vue` changes) backed by a new `catalog_item` register/schema, itself populated at boot/repair time from a small new `CatalogRegistryService`. The Configuration import/export UI resurrects a thin, spec-referenced `ConfigurationController` over the pre-existing `ConfigurationService` rather than duplicating its logic or routing through OpenRegister's generic (register-scoped, not configuration-group-scoped) export/import endpoints, which do not model Integriq's own configuration-group semantics (sources+endpoints+mappings+rules+jobs+syncs bundled by `configurations[]` membership). Full technical detail in `design.md`. - -## New Dependencies - -None. No new npm/composer packages; reuses existing `@conduction/nextcloud-vue` primitives, existing `ConfigurationService`, existing OR `IntegrationRegistry`. - -## Impact - -- New: `lib/Controller/ConfigurationController.php`, `lib/Service/CatalogRegistryService.php`, `lib/Settings/register.d/catalog-item-schema.json` (or equivalent schema fragment), a repair/migration step to materialise `catalog_item` objects, `appinfo/routes.php` entries, `src/manifest.json` `Catalog` page + menu entry, a `CatalogItemCard.vue` card component, an import-preview modal, a catalog Pinia store. -- Touched: `lib/actions.seed.json` — three new ADR-023 action keys appended (`catalog.instantiate`, `configuration.export`, `configuration.import`, each `["admin"]`) to the existing 38-action matrix seed; enforced by the existing `lib/Service/ActionAuthService.php` and applied by the existing `lib/Repair/InitializeActions.php`, both reused unchanged. None of the existing Source/Configuration CRUD paths change behaviour — this is additive (a new read/browse surface + a new write path for import that reuses the existing `ConfigurationService` write logic unchanged). -- No database schema changes to existing Integriq tables; `catalog_item` is a new OpenRegister schema, not a native table. - -## Cross-Project Dependencies - -Depends on OpenRegister's `IntegrationRegistry` (`OCA\OpenRegister\Service\Integration\IntegrationRegistry`) for the 4 already-registered category adapters — read-only consumption, no changes requested to OpenRegister. No other apps are affected; nothing in `apps-extra` currently consumes Integriq's configuration export/import surface. - -## Risks - -### Risk 1: "Enable"/"Instantiate" actions bypass or duplicate authorization already enforced elsewhere -**Severity:** High — **Mitigation:** the Catalog page's actions call into the *existing* Source/Configuration create/update code paths (already governed by ADR-023's action matrix and the `99-source-lockdown.json` admin-only CRUD lock on the `source` schema) rather than introducing a new write path; the catalog action handler is a thin dispatcher, not a new authorization surface. Verified in design.md against `99-source-lockdown.json`. - -### Risk 2: Two incompatible "dormant" mechanisms (container `*.feature_flag` app-config vs. per-object `configuration.mock`/`isEnabled`) collapse into one UI affordance incorrectly -**Severity:** Medium — **Mitigation:** the catalog registry's status field distinguishes the mechanism explicitly (`flag-gated` vs. `mock-seeded`) and the detail-modal action dispatches to the correct handler per mechanism; specs enumerate both paths as separate scenarios. - -### Risk 3: Resurrecting a `ConfigurationController` reopens an unrouted, security-relevant surface (substring-based, not allowlist, credential redaction per REQ-005 Notes) -**Severity:** Medium — **Mitigation:** the controller is additive over already-audited logic; the import endpoint is admin-only (mirrors the `source` schema's admin-only lockdown), and the redaction gap is pre-existing and documented (not introduced by this change) — noted explicitly in the spec delta rather than silently relied upon. - -### Risk 4: `catalog_item` materialisation drifts from the live registry state (stale cards) -**Severity:** Low — **Mitigation:** materialisation runs on every repair-step pass (same cadence as existing `register.d` fragment application), and feature-flag/mock status is read live at request time by the catalog API endpoint, not baked into the stored object, so status badges cannot go stale even if the object list itself is momentarily behind. - -## Rollback Strategy - -Entirely additive: remove the `Catalog` page + menu entry from `src/manifest.json`, remove the `ConfigurationController` route registrations, and drop the `catalog_item` schema fragment (its repair step is idempotent and re-runnable). No existing Source/Endpoint/Configuration data or behaviour is touched, so rollback carries no data-migration risk. - -## Open Questions - -- Should PDOK/Digikoppeling/Berichtenbox/DSO be promoted to full OR `IntegrationRegistry` `IntegrationProvider`s in a follow-up so the catalog has one registry instead of two sources (registry + descriptor list)? Deferred — see design.md decision and DEFERRED_QUESTIONS. -- Should the import-preview UI surface the REQ-004 "unresolvable slug left verbatim" dangling-reference risk as a blocking warning, or an informational note? Proposed: blocking warning requiring explicit acknowledgement, since it is a silent-failure mode today (see configuration-export-import delta). +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: connector-catalog-ui (superseded — retired 2026-09-02) + +This directory double-counted a change that had already shipped. The +connector catalog was implemented and archived on 2026-07-14 +(`archive/2026-07-14-connector-catalog-ui`, 32/41 tasks checked with +per-task evidence), yet this live copy was resurrected at 0/41: the +openconnector→integriq rename applied to the prose, the evidence notes +stripped, every box reset. The machinery exists at HEAD: +`lib/Controller/CatalogController.php`, `lib/Service/CatalogRegistryService.php`, +`lib/Repair/MaterializeCatalogItems.php`, the `catalog_item` schema in +`lib/Settings/register.d/catalog-item-schema.json`, the catalog routes in +`appinfo/routes.php`, the Catalog UI (`src/components/CatalogItemCard.vue`, +`src/dialogs/CatalogItemDetailDialog.vue`, import/export dialogs), and the +authored e2e specs (`tests/e2e/spec-coverage/connector-catalog.spec.ts`, +`configuration-import-export-ui.spec.ts`). + +`appinfo/routes.php` and `lib/Settings/register.d/catalog-item-schema.json` +reference this directory's `contract.md` and `design.md`, so those two files +stay exactly where they are as reference targets. The other artifacts +(context brief, discovery, migration, test plan, spec deltas) are removed; +they survive verbatim in the archived twin and in git history. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `catalog_item` schema, materialisation repair step, registry service, controller + routes, Catalog page, item detail dialog, import preview / export dialogs, ADR-023 `catalog.instantiate` entry, unit + vitest coverage, authored e2e specs | **Already shipped and archived**: `archive/2026-07-14-connector-catalog-ui` (32/41 boxes checked), code at HEAD | +| Residual verification: executing the two authored Playwright specs against a live instance, Newman coverage for the catalog endpoints, a Catalog-page screenshot in `docs/images/` | Open, and honestly unticked in the archived twin (each open box carries its reason: no live instance in that build environment). Same shape as `approvals-verification-pack`; pick up in a verification pass, not by resurrecting this change | + +## Sequencing + +Nothing remains to implement from this change directly. The residual +live-instance verification belongs to a verification-pack-style follow-up. + +## Archival + +This directory is retired in place (not moved or renamed): `contract.md` and +`design.md` are referenced from `appinfo/routes.php` and the register +fragment, and a rename would break those pointers and detonate every +diff-scoped gate. Archive it via the normal flow only after those comments +are repointed. diff --git a/openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md b/openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md deleted file mode 100644 index 383e92160..000000000 --- a/openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md +++ /dev/null @@ -1,62 +0,0 @@ -# configuration-export-import Specification (delta: connector-catalog-ui) - -## ADDED Requirements - -### Requirement: REQ-006 — Export a configuration from the UI - -The system SHALL expose the existing `ConfigurationService::exportConfiguration()` (REQ-001–REQ-005, unchanged) through a routed `POST /api/configurations/{id}/export` endpoint and a Configuration UI page action, so an operator can download a redacted configuration document without using the API directly. The endpoint SHALL be gated by Integriq's existing `ActionAuthService::requireAction()` (ADR-023) with a new action key `configuration.export`, seeded `["admin"]` in the existing `lib/actions.seed.json`. - -Notes: This requirement adds reachability only; it does not change REQ-001–REQ-005's export, slug-translation, or redaction behaviour, including the documented substring-match redaction gap (REQ-005 Notes) and the O(all entities) cost note (REQ-001 Notes). - -#### Scenario: Exporting a configuration from the UI produces a redacted downloadable file -- GIVEN a configuration group containing a Source with `apikey = "live_xyz"` -- WHEN an operator with the `configuration.export` action permission clicks "Export" on that configuration in the UI -- THEN the browser downloads a JSON file -- AND the file does not contain `apikey`, `secret`, or any other REQ-005 redacted field - -#### Scenario: A user without the configuration.export action permission cannot export -- GIVEN a non-admin user whose groups are not mapped to `configuration.export` in the admin-configured action matrix (admins always pass `requireAction()` — documented break-glass behaviour) -- WHEN that user calls the export endpoint -- THEN the request is rejected with `OCSForbiddenException` and no file is produced - -### Requirement: REQ-007 — Preview an import before writing anything - -The system SHALL expose a non-mutating `POST /api/configurations/import/preview` endpoint that, given an OAS document, computes and returns the same creates/updates/collisions classification that `importConfiguration()` (REQ-003) would perform, plus the set of unresolved slug references (REQ-004's "left verbatim" case) that would result, WITHOUT calling `saveObject()` on any entity. The preview SHALL reuse the existing handlers' slug-resolution logic (`resetMappings()`, per-type `import()` dry-run mode) rather than reimplementing it. - -#### Scenario: Preview classifies creates, updates and collisions -- GIVEN an OAS document containing one Source whose slug exists in the target environment and one Source whose slug does not -- WHEN `POST /api/configurations/import/preview` is called with that document -- THEN the response lists the existing-slug Source under `updates` and the new-slug Source under `creates` -- AND no Source object is created or modified by the preview call - -#### Scenario: Preview surfaces an unresolvable slug reference as a blocking warning -- GIVEN an OAS document containing a Rule whose nested configuration references a Source slug that does not exist in the target environment (the REQ-004 "unresolvable slug is left verbatim" case) -- WHEN the import is previewed -- THEN the response's `unresolvedReferences` array contains that Rule's slug and the unresolved field -- AND the import UI marks this as a blocking warning requiring explicit operator acknowledgement before the import can be confirmed - -### Requirement: REQ-008 — Import requires explicit confirmation after preview - -The system SHALL require a `confirmed: true` flag on `POST /api/configurations/import` and SHALL reject the request with HTTP 400 if it is absent, so that no import write occurs without the caller having first retrieved and (per the UI) displayed a preview. Both the import and preview endpoints SHALL be gated by the existing `ActionAuthService::requireAction()` (ADR-023) with a new action key `configuration.import` seeded `["admin"]` in the existing `lib/actions.seed.json`, and the underlying entity writes SHALL continue to pass through each entity type's existing OpenRegister data-layer authorization unchanged (e.g. Source writes remain admin-only per the `source` schema lock). - -#### Scenario: Import without confirmation is rejected -- GIVEN a valid OAS document -- WHEN `POST /api/configurations/import` is called with `confirmed` omitted or `false` -- THEN the response is HTTP 400 -- AND no entity is created or updated - -#### Scenario: Confirmed import proceeds and reuses the existing import pipeline unchanged -- GIVEN a valid OAS document and `confirmed: true` -- WHEN `POST /api/configurations/import` is called -- THEN the system delegates to the existing `ConfigurationService::importConfiguration()` (REQ-003) unmodified -- AND the response reflects what was actually created and updated - -### Requirement: REQ-009 — Imported Sources with redacted credentials are flagged for re-entry - -The system SHALL, in both the preview and post-import response, list every imported Source object whose credential fields were stripped by REQ-005's redaction (i.e. every Source in the import document, since export always redacts) under `credentialsNeedingReentry`, naming the fields that require operator re-entry, so the UI can direct the operator to the Source's edit form after import completes. - -#### Scenario: A newly created Source from import is flagged for credential re-entry -- GIVEN an OAS document containing a Source with no `apikey`/`secret`/`username`/`password` fields (because REQ-005 stripped them on export) -- WHEN the import is confirmed and the Source is created -- THEN the response's `credentialsNeedingReentry` array contains that Source's slug and the list of credential field names it is missing -- AND the created Source object itself contains no credential values, matching the existing REQ-005 "imported source has no credentials and needs re-entry" scenario diff --git a/openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md b/openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md deleted file mode 100644 index d11352c9f..000000000 --- a/openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -status: planned ---- - -# connector-catalog Specification - -**Status**: planned -**Scope**: integriq -**OpenSpec changes**: -- connector-catalog-ui - -## Purpose - -Integriq ships real integration capability — seeded PDOK/BRP/KVK/xWiki/messaging/OpenCorporates sources and four registered category adapters (Azure Virtual Desktop, SharePoint Online, Microsoft 365, S3) — but none of it is browsable. This capability defines a Catalog: a single, register/schema-backed, card-grid page that lists every built-in connector adapter, seeded source template, and importable configuration template, with search, category filtering, live status badges (`available` vs `dormant`), and an authorized Enable/Instantiate action. It is populated by a PHP-side `CatalogRegistryService` that reads the existing OpenRegister `IntegrationRegistry`, a small static descriptor list for adapters not yet in that registry, and the `register.d/*-source.json` seed fragments — no catalog entry is invented; every entry corresponds to real, already-shipped code. See ADR-023 (action-level authorization) for the Enable/Instantiate authorization model. - -## ADDED Requirements - -### Requirement: Catalog lists adapters, seeded source templates and configuration templates with category filter and status badges (REQ-001) - -The system MUST provide a Catalog page listing every registered `catalog_item` object, grouped by `kind` (`adapter`, `source-template`, `configuration-template`), each rendered as a card showing name, category, standards, and a live status badge (`available` or `dormant`). The page MUST support free-text search and a category facet filter, and MUST NOT require a bespoke `type: "custom"` manifest page to do so — the manifest-v2 `type: "index"` page with `config.viewMode: "cards"` MUST be used (see `openconnector-app-manifest` delta). - -#### Scenario: Catalog lists built-in adapters and seeded source templates by category -- GIVEN the `catalog_item` register contains entries for the PDOK WMS adapter (category "Geo / Maps"), the BRP HaalCentraal seeded source (category "Government registers"), and the S3 data-infra adapter (category "Data infrastructure") -- WHEN an operator opens the Catalog page -- THEN all three items are rendered as cards -- AND selecting the "Government registers" category filter narrows the grid to only the BRP HaalCentraal card - -#### Scenario: Status badge reflects a flag-gated dormant item -- GIVEN the PDOK WMS catalog item has `mechanism: "flag-gated"` and the `pdok.feature_flag` app-config value is unset (default off) -- WHEN the Catalog page renders the PDOK WMS card -- THEN its status badge reads "dormant" - -#### Scenario: Status badge reflects a mock-seeded available item -- GIVEN the BRP HaalCentraal catalog item has `mechanism: "mock-seeded"` and its underlying Source object has `isEnabled: true` and `configuration.mock: true` -- WHEN the Catalog page renders the BRP HaalCentraal card -- THEN its status badge reads "available" (mock mode is not treated as dormant — the source is reachable, just returning canned data) - -#### Scenario: Search narrows the catalog grid -- GIVEN the Catalog page is open with no filters applied -- WHEN an operator types "brp" into the search field -- THEN only catalog items whose name or description matches "brp" remain visible - -### Requirement: Catalog detail modal offers an authorized Enable or Instantiate action (REQ-002) - -The system MUST provide a detail modal for each catalog item, opened from its card, showing the item's full description and standards, plus a primary action: "Enable" for a `flag-gated` item, or "Instantiate" for a `mock-seeded` or `always-available` item. The action MUST be gated at the action layer by Integriq's existing ADR-023 implementation — `ActionAuthService::requireAction()` (`lib/Service/ActionAuthService.php`) against a new `catalog.instantiate` action key seeded `["admin"]` in the existing `lib/actions.seed.json` (following its established `.` naming, e.g. `source.test`, `job.run`) — and MUST still pass through the underlying OpenRegister data-layer authorization for the object being created or updated (e.g. the `source` schema's admin-only lock). The catalog action MUST NOT introduce a new authorization service or a bypass of existing data-layer authorization. - -#### Scenario: Enable action flips a feature flag for a flag-gated item -- GIVEN an operator with the `catalog.instantiate` action permission opens the PDOK WMS detail modal while it is dormant -- WHEN the operator clicks "Enable" -- THEN the system sets the `pdok.feature_flag` app-config value to enabled -- AND the catalog item's status badge updates to "available" on next status check - -#### Scenario: Instantiate action creates a Source from a seeded template -- GIVEN an operator with the `catalog.instantiate` action permission opens a seeded source-template catalog item that has not yet been instantiated as a live Source -- WHEN the operator clicks "Instantiate" -- THEN a new Source object is created in the `openconnector` register from the template -- AND the response indicates the created Source's id - -#### Scenario: A user without the catalog.instantiate action permission cannot enable or instantiate -- GIVEN a non-admin user whose groups are not mapped to the `catalog.instantiate` action in the admin-configured matrix (admins always pass `ActionAuthService::requireAction()` — documented break-glass behaviour) -- WHEN that user calls the instantiate endpoint for any catalog item -- THEN the request is rejected with `OCSForbiddenException` before any Source or app-config write occurs - -#### Scenario: Instantiate action still respects the Source schema's data-layer admin-only lock -- GIVEN an operator's groups ARE mapped to `catalog.instantiate` in the action matrix, but that operator is not a Nextcloud admin -- WHEN the operator calls the instantiate endpoint for a source-template catalog item -- THEN the underlying Source create call is rejected by OpenRegister's admin-only authorization on the `source` schema, independent of the action-matrix result - -### Requirement: A single PHP-side adapter metadata registry is the source of truth for catalog entries (REQ-003) - -The system MUST assemble catalog entries from exactly one service, `CatalogRegistryService`, which MUST source its data from (a) OpenRegister's existing `IntegrationRegistry` for adapters already registered there, (b) a static descriptor list for built-in adapters not registered there, and (c) the `register.d/*-source.json` seed fragments for seeded source templates. The frontend MUST NOT hardcode any catalog entry — every card rendered on the Catalog page MUST originate from a `catalog_item` OpenRegister object materialized by this service. - -#### Scenario: A newly registered IntegrationRegistry provider appears in the catalog without a frontend change -- GIVEN a fifth `IntegrationProvider` is registered into OpenRegister's `IntegrationRegistry` by Integriq -- WHEN the next `CatalogRegistryService` materialization repair-step run occurs -- THEN a corresponding `catalog_item` object is created or updated -- AND it appears on the Catalog page without any change to `CatalogItemCard.vue` or the manifest - -#### Scenario: Materialization is idempotent -- GIVEN a `catalog_item` object already exists for the PDOK WMS adapter with a given slug -- WHEN the materialization repair step runs again with no underlying change -- THEN the existing object is updated in place (not duplicated) - -## Non-Functional Requirements - -- **Performance:** Catalog page list and search MUST use OpenRegister's standard object-list endpoint (no bespoke N+1 status check per card on initial render); the live per-item status re-check (REQ-002 scenarios) is deferred to the detail-modal open, not the grid render. -- **Accessibility:** Category filter chips and the search field MUST carry accessible labels (WCAG 2.1 AA), consistent with existing `CnIndexPage` facet-filter usage elsewhere in the fleet. -- **Internationalization:** All catalog item labels, category names, and action labels MUST be translatable via the existing i18n mechanism; i18n keys MUST be English source strings (per fleet convention). - -## Acceptance Criteria - -- [ ] Catalog page renders as a manifest-v2 `type: "index"` + `viewMode: "cards"` page — no new `nextcloud-vue` component or schema. -- [ ] Every catalog card corresponds to a real, already-shipped adapter or seeded source (no fabricated entries). -- [ ] Enable/Instantiate action is gated by both the ADR-023 action matrix and existing OpenRegister data-layer authorization. -- [ ] Catalog materialization repair step is idempotent and re-runnable without duplication. - -## Notes - -Deferred: promoting PDOK/Digikoppeling/Berichtenbox/DSO into full `IntegrationRegistry` `IntegrationProvider`s (see design.md Trade-offs) — this capability consumes them via a lighter descriptor list instead. A community/remote template marketplace is explicitly out of scope (see proposal.md). diff --git a/openspec/changes/connector-catalog-ui/specs/openconnector-app-manifest/spec.md b/openspec/changes/connector-catalog-ui/specs/openconnector-app-manifest/spec.md deleted file mode 100644 index 8e86a6d97..000000000 --- a/openspec/changes/connector-catalog-ui/specs/openconnector-app-manifest/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -# openconnector-app-manifest Specification (delta: connector-catalog-ui) - -## ADDED Requirements - -### Requirement: Manifest MUST declare a Catalog page and menu entry - -The manifest `pages` array MUST contain an entry with `id: "Catalog"`, `route: "/catalog"`, `type: "index"`, backed by `config.register: "openconnector"` and `config.schema: "catalog_item"`, with `config.viewMode: "cards"` and a `config.cardComponent` set. The manifest `menu` array MUST contain a corresponding entry (`id: "Catalog"`, `route: "Catalog"`) so the page is reachable from primary navigation. - -Notes: This requirement is scoped narrowly to the Catalog addition. It does not attempt to reconcile the base `openconnector-app-manifest` spec's existing drift against `src/manifest.json` at HEAD (missing `roadmap` type in the type enum, stale page count/list, a phantom `Import` page, `Settings` vs. `AppSettings` id mismatch, flat-vs-grouped menu) — that drift predates this change and is out of scope here (see connector-catalog-ui proposal.md Out of Scope). - -#### Scenario: Catalog page entry is present and uses the cards index pattern -- GIVEN the manifest file is loaded -- WHEN inspecting the page with id `"Catalog"` -- THEN its `type` field MUST be `"index"` -- AND `config.viewMode` MUST be `"cards"` -- AND `config.register` MUST be `"openconnector"` and `config.schema` MUST be `"catalog_item"` - -#### Scenario: Catalog menu entry is present and routes to the Catalog page -- GIVEN the manifest file is loaded -- WHEN inspecting `manifest.menu` (including nested `children` arrays, per the existing grouped-nav structure) -- THEN an entry with id `"Catalog"` MUST exist -- AND its `route` MUST equal `"Catalog"`, matching the `pages[].id` of the Catalog page entry - -#### Scenario: Catalog page does not require a new manifest page type -- GIVEN the manifest schema's `pages[].type` enum -- WHEN validating the Catalog page entry against it -- THEN validation succeeds using the existing `"index"` type — no new type value is introduced by this change diff --git a/openspec/changes/connector-catalog-ui/tasks.md b/openspec/changes/connector-catalog-ui/tasks.md index 94d62230d..31a96ac01 100644 --- a/openspec/changes/connector-catalog-ui/tasks.md +++ b/openspec/changes/connector-catalog-ui/tasks.md @@ -1,161 +1,13 @@ -# Tasks: connector-catalog-ui - -## Implementation Tasks - -### Task 1: Seed the three new actions into the existing ADR-023 matrix -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **files**: `lib/actions.seed.json` -- **acceptance_criteria**: - - GIVEN the existing `lib/actions.seed.json` (38 actions, `.` convention — `source.test`, `job.run`, `pdok.suggest`) WHEN `catalog.instantiate`, `configuration.export`, `configuration.import` are appended, each `["admin"]` THEN the existing `lib/Repair/InitializeActions.php` applies them on its next run and the existing admin Action authorization panel (`ActionMatrixController`) lists all three - - No new auth service or controller is created — the existing `lib/Service/ActionAuthService.php::requireAction()` is reused unchanged by Tasks 5, 8, 9 and 10 -- [ ] Implement -- [ ] Test - -### Task 2: catalog_item schema fragment -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **files**: `lib/Settings/register.d/catalog-item-schema.json` -- **acceptance_criteria**: - - GIVEN the fragment is shipped WHEN `occ upgrade` runs THEN `catalog_item` appears as a schema under the `openconnector` register with fields `name`, `description`, `category`, `kind`, `mechanism`, `flagKey`, `sourceTemplateSlug`, `standards`, `icon` -- [ ] Implement -- [ ] Test - -### Task 3: CatalogRegistryService — assemble catalog entries from existing sources -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-a-single-php-side-adapter-metadata-registry-is-the-source-of-truth-for-catalog-entries-req-003` -- **files**: `lib/Service/CatalogRegistryService.php` -- **acceptance_criteria**: - - GIVEN OpenRegister's `IntegrationRegistry` has 4 registered providers WHEN `CatalogRegistryService::collect()` runs THEN it returns 4 descriptor entries sourced from that registry, plus static entries for PDOK/Digikoppeling/Berichtenbox/DSO, plus one entry per `register.d/*-source.json` seed fragment found - - GIVEN a fifth provider is registered into `IntegrationRegistry` WHEN `collect()` runs again THEN a 5th entry appears with no code change to the static descriptor list -- [ ] Implement -- [ ] Test - -### Task 4: MaterializeCatalogItems repair step -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-a-single-php-side-adapter-metadata-registry-is-the-source-of-truth-for-catalog-entries-req-003` -- **files**: `lib/Repair/MaterializeCatalogItems.php`, `lib/AppInfo/Application.php` -- **acceptance_criteria**: - - GIVEN `CatalogRegistryService::collect()` returns N entries WHEN the repair step runs THEN N `catalog_item` objects exist, keyed by stable `kind:slug` - - GIVEN the repair step runs a second time with no underlying change WHEN it completes THEN the object count is unchanged (idempotent upsert, not duplicate creation) -- [ ] Implement -- [ ] Test - -### Task 5: CatalogController — status + instantiate endpoints -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **files**: `lib/Controller/CatalogController.php`, `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN a flag-gated catalog item WHEN `GET /api/catalog/items/{id}/status` is called THEN the response reflects the live `IConfig` value for its `flagKey` - - GIVEN an operator with `catalog.instantiate` permission WHEN `POST /api/catalog/items/{id}/instantiate` is called on a flag-gated item THEN the app-config flag is enabled - - GIVEN an operator with `catalog.instantiate` permission WHEN the same endpoint is called on a mock-seeded/template item not yet instantiated THEN a new Source object is created - - GIVEN a user without `catalog.instantiate` permission WHEN either endpoint is called THEN the request is rejected before any write - - GIVEN an operator with `catalog.instantiate` permission but not a Nextcloud admin WHEN instantiate is called THEN the underlying Source write is still rejected by the `source` schema's admin-only OpenRegister authorization -- [ ] Implement -- [ ] Test - -### Task 6: Catalog manifest page + card component -- **spec_ref**: `openspec/specs/openconnector-app-manifest/spec.md#requirement-manifest-must-declare-a-catalog-page-and-menu-entry` -- **files**: `src/manifest.json`, `src/components/CatalogItemCard.vue`, `src/store/catalog.js` -- **acceptance_criteria**: - - GIVEN the manifest is loaded WHEN inspecting the `Catalog` page entry THEN `type` is `"index"`, `config.viewMode` is `"cards"`, `config.register`/`config.schema` are `"openconnector"`/`"catalog_item"` - - GIVEN the manifest is loaded WHEN inspecting `menu` THEN a `Catalog` entry routes to the `Catalog` page id - - GIVEN the Catalog page is open WHEN the category filter is applied THEN only matching cards render (uses `CnIndexPage`'s existing `filters` config, no new component logic beyond the card itself) -- [ ] Implement -- [ ] Test - -### Task 7: Catalog detail modal (Enable / Instantiate) -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **files**: `src/dialogs/CatalogItemDetailDialog.vue` -- **acceptance_criteria**: - - GIVEN a card is clicked WHEN the detail dialog opens THEN it shows description, standards, and a live status re-check via `GET /api/catalog/items/{id}/status` - - GIVEN the item is dormant WHEN the operator clicks the primary action THEN the correct endpoint (Enable vs Instantiate) is called based on `mechanism` -- [ ] Implement -- [ ] Test - -### Task 8: ConfigurationController — export -- **spec_ref**: `openspec/specs/configuration-export-import/spec.md#requirement-req-006-export-a-configuration-from-the-ui` -- **files**: `lib/Controller/ConfigurationController.php`, `appinfo/routes.php` -- **acceptance_criteria**: - - GIVEN a configuration group with a Source containing credentials WHEN `POST /api/configurations/{id}/export` is called by an authorized operator THEN a redacted JSON file is returned matching the existing `ConfigurationService::exportConfiguration()` output unchanged - - GIVEN a user without `configuration.export` permission WHEN the endpoint is called THEN the request is rejected -- [ ] Implement -- [ ] Test - -### Task 9: ConfigurationController — import preview (non-mutating) -- **spec_ref**: `openspec/specs/configuration-export-import/spec.md#requirement-req-007-preview-an-import-before-writing-anything` -- **files**: `lib/Controller/ConfigurationController.php`, `lib/Service/ConfigurationImportPreviewService.php` -- **acceptance_criteria**: - - GIVEN an OAS document with one existing-slug and one new-slug Source WHEN `POST /api/configurations/import/preview` is called THEN the response correctly classifies each under `updates`/`creates` and no object is written - - GIVEN an OAS document with a Rule referencing an unresolvable Source slug WHEN previewed THEN `unresolvedReferences` lists it -- [ ] Implement -- [ ] Test - -### Task 10: ConfigurationController — confirmed import -- **spec_ref**: `openspec/specs/configuration-export-import/spec.md#requirement-req-008-import-requires-explicit-confirmation-after-preview` -- **files**: `lib/Controller/ConfigurationController.php` -- **acceptance_criteria**: - - GIVEN `confirmed` is omitted or false WHEN `POST /api/configurations/import` is called THEN the response is HTTP 400 and nothing is written - - GIVEN `confirmed: true` WHEN called THEN the system delegates unchanged to `ConfigurationService::importConfiguration()` and returns what was created/updated -- [ ] Implement -- [ ] Test - -### Task 11: Credential re-entry flagging in import response -- **spec_ref**: `openspec/specs/configuration-export-import/spec.md#requirement-req-009-imported-sources-with-redacted-credentials-are-flagged-for-re-entry` -- **files**: `lib/Service/ConfigurationImportPreviewService.php` -- **acceptance_criteria**: - - GIVEN an imported Source document with no credential fields WHEN import completes THEN the response's `credentialsNeedingReentry` lists that Source's slug and missing field names -- [ ] Implement -- [ ] Test - -### Task 12: Configuration import/export UI page + preview dialog -- **spec_ref**: `openspec/specs/configuration-export-import/spec.md#requirement-req-006-export-a-configuration-from-the-ui` -- **files**: `src/manifest.json`, `src/dialogs/ImportPreviewDialog.vue` -- **acceptance_criteria**: - - GIVEN an operator uploads an OAS document WHEN the preview dialog opens THEN it shows creates/updates/collisions/unresolved-references, and any unresolved reference blocks confirmation until acknowledged - - GIVEN the operator confirms WHEN the import completes THEN a post-import summary shows `credentialsNeedingReentry` with links to each Source's edit form -- [ ] Implement -- [ ] Test - -### Task 13: PHPUnit — CatalogRegistryService + import preview diff logic -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-a-single-php-side-adapter-metadata-registry-is-the-source-of-truth-for-catalog-entries-req-003` -- **files**: `tests/Unit/Service/CatalogRegistryServiceTest.php`, `tests/Unit/Service/ConfigurationImportPreviewServiceTest.php` -- **acceptance_criteria**: - - GIVEN mocked `IntegrationRegistry` + seed fragments WHEN `collect()` is unit-tested THEN entry count and shape are asserted - - GIVEN a fixture OAS document with a known create/update/collision/unresolved mix WHEN the preview service is unit-tested THEN each category is asserted -- [ ] Implement -- [ ] Test - -### Task 14: vitest — catalog Pinia store -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **files**: `src/store/catalog.spec.js` -- **acceptance_criteria**: - - GIVEN the catalog store is loaded WHEN filtering by category or search term THEN the store's filtered getter returns the expected subset -- [ ] Implement -- [ ] Test - -### Task 15: Playwright e2e — catalog browse + import flow -- **spec_ref**: `openspec/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **files**: `tests/playwright/catalog-browse.spec.js`, `tests/playwright/configuration-import.spec.js` -- **acceptance_criteria**: - - GIVEN a logged-in admin WHEN they navigate to `/catalog`, filter by category, open a detail modal, and instantiate a seeded source THEN the new Source appears in the Sources list - - GIVEN an admin exports a configuration, then re-imports the downloaded file WHEN the preview dialog appears THEN it shows the expected update classification and completing the import re-flags credential re-entry -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/`) -- [ ] Newman/Postman tests for new/changed API endpoints -- [ ] Browser tests (Playwright MCP) for UI changes -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` -- [ ] Screenshot captured and committed to `docs/images/` - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for Catalog page labels, category names, status badges, and import-preview dialog text +# Tasks: connector-catalog-ui (superseded) + +The original 15-task / 41-checkbox list was removed with the 2026-09-02 +retirement (see proposal.md for the disposition; the list survives in +`archive/2026-07-14-connector-catalog-ui/tasks.md`, where 32/41 boxes are +checked with per-task evidence, and in git history). The residual +live-instance verification (running the authored Playwright specs, Newman, +a screenshot) is listed there with per-box reasons and belongs to a +verification-pack-style follow-up. + +`contract.md` and `design.md` stay in this directory as reference targets +for `appinfo/routes.php` and `lib/Settings/register.d/catalog-item-schema.json`. +There is nothing to implement from this change directly. diff --git a/openspec/changes/connector-catalog-ui/test-plan.md b/openspec/changes/connector-catalog-ui/test-plan.md deleted file mode 100644 index 23a473f48..000000000 --- a/openspec/changes/connector-catalog-ui/test-plan.md +++ /dev/null @@ -1,180 +0,0 @@ -# Test Plan: connector-catalog-ui - -## Test Cases - -### TC-1: Catalog lists items grouped by category -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **type**: functional -- **persona**: Noor (municipal CISO / functional admin — the operator who onboards new connectors) -- **preconditions**: `catalog_item` objects materialised for PDOK WMS, BRP HaalCentraal, S3 adapter -- **steps**: navigate to `/catalog`; apply the "Government registers" category filter -- **expected result**: only the BRP HaalCentraal card remains visible -- **test command**: `/test-functional` - -### TC-2: Status badge distinguishes flag-gated dormant vs mock-seeded available -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **type**: functional -- **persona**: Noor -- **preconditions**: `pdok.feature_flag` unset (default off); BRP source `isEnabled: true`, `configuration.mock: true` -- **steps**: open `/catalog`; inspect the PDOK WMS and BRP HaalCentraal cards -- **expected result**: PDOK WMS badge reads "dormant"; BRP HaalCentraal badge reads "available" -- **test command**: `/test-functional` - -### TC-3: Search narrows the catalog grid -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-lists-adapters-seeded-source-templates-and-configuration-templates-with-category-filter-and-status-badges-req-001` -- **type**: functional -- **preconditions**: catalog populated with ≥3 items, only one matching "brp" -- **steps**: type "brp" into the catalog search field -- **expected result**: only the matching item(s) remain visible -- **test command**: `/test-functional` - -### TC-4: Enable action flips a feature flag (flag-gated item) -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **type**: functional -- **persona**: Noor -- **preconditions**: operator has `catalog.instantiate` action permission; PDOK WMS dormant -- **steps**: open PDOK WMS detail modal; click "Enable" -- **expected result**: `pdok.feature_flag` becomes enabled; status badge updates to "available" on next check -- **test command**: `/test-functional` - -### TC-5: Instantiate action creates a Source from a seeded template -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **type**: functional -- **persona**: Noor -- **preconditions**: operator has `catalog.instantiate` permission; a not-yet-instantiated source-template item exists -- **steps**: open its detail modal; click "Instantiate" -- **expected result**: a new Source object appears in the Sources list -- **test command**: `/test-functional` - -### TC-6: catalog.instantiate action denial blocks the write (API) -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **type**: security -- **preconditions**: a non-admin user whose groups are NOT mapped to `catalog.instantiate` (admins always pass the existing `ActionAuthService::requireAction()` break-glass) -- **steps**: `POST /api/catalog/items/{id}/instantiate` as that user -- **expected result**: request rejected (403 via `OCSForbiddenException` from the existing `ActionAuthService`), no Source or app-config write occurs -- **test command**: `/test-security` - -### TC-7: Action-matrix pass but data-layer admin-only lock still blocks a non-admin -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-catalog-detail-modal-offers-an-authorized-enable-or-instantiate-action-req-002` -- **type**: security -- **preconditions**: a non-admin user IS mapped to `catalog.instantiate` in the action matrix -- **steps**: `POST /api/catalog/items/{id}/instantiate` (source-template item) as that user -- **expected result**: the underlying Source create is rejected by OpenRegister's admin-only `source` schema authorization, independent of the action-matrix pass -- **test command**: `/test-security` - -### TC-8: Catalog materialises new IntegrationRegistry providers with no frontend change -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-a-single-php-side-adapter-metadata-registry-is-the-source-of-truth-for-catalog-entries-req-003` -- **type**: api -- **preconditions**: a 5th `IntegrationProvider` registered into `IntegrationRegistry` -- **steps**: run the `MaterializeCatalogItems` repair step; call `GET /apps/openregister/api/objects/integriq/catalog_item` -- **expected result**: a 5th `catalog_item` object exists -- **test command**: `/test-api` - -### TC-9: Materialization is idempotent across repeated runs -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#requirement-a-single-php-side-adapter-metadata-registry-is-the-source-of-truth-for-catalog-entries-req-003` -- **type**: api -- **preconditions**: `catalog_item` objects already materialised once -- **steps**: run the repair step a second time with no underlying change -- **expected result**: object count unchanged, no duplicates -- **test command**: `/test-api` - -### TC-10: Export from UI produces a redacted file -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-006-export-a-configuration-from-the-ui` -- **type**: functional -- **persona**: Noor -- **preconditions**: a configuration group with a Source carrying `apikey`/`secret`; operator has `configuration.export` permission -- **steps**: open the Configuration UI page for that group; click "Export" -- **expected result**: downloaded JSON file contains no `apikey`, `secret`, or other REQ-005-redacted field -- **test command**: `/test-functional` - -### TC-11: configuration.export action denial blocks export (API) -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-006-export-a-configuration-from-the-ui` -- **type**: security -- **preconditions**: non-admin user not mapped to `configuration.export` (admins always pass the existing `ActionAuthService` break-glass) -- **steps**: `POST /api/configurations/{id}/export` -- **expected result**: 403, no file produced -- **test command**: `/test-security` - -### TC-12: Import preview classifies creates/updates/collisions without writing -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-007-preview-an-import-before-writing-anything` -- **type**: api -- **preconditions**: OAS document with one existing-slug Source and one new-slug Source -- **steps**: `POST /api/configurations/import/preview` -- **expected result**: response correctly classifies each; no object is created or modified -- **test command**: `/test-api` - -### TC-13: Preview surfaces an unresolvable slug reference as a blocking warning -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-007-preview-an-import-before-writing-anything` -- **type**: functional -- **preconditions**: OAS document with a Rule referencing a non-existent Source slug -- **steps**: upload the document in the import UI; observe the preview dialog -- **expected result**: `unresolvedReferences` is shown; the confirm button is disabled until the operator explicitly acknowledges the warning -- **test command**: `/test-functional` - -### TC-14: Import without confirmation is rejected -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-008-import-requires-explicit-confirmation-after-preview` -- **type**: api -- **preconditions**: valid OAS document -- **steps**: `POST /api/configurations/import` with `confirmed` omitted -- **expected result**: HTTP 400, nothing written -- **test command**: `/test-api` - -### TC-15: Confirmed import writes via the existing unchanged pipeline -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-008-import-requires-explicit-confirmation-after-preview` -- **type**: api -- **preconditions**: valid OAS document -- **steps**: `POST /api/configurations/import` with `confirmed: true` -- **expected result**: response reflects actual creates/updates; entities appear in their respective index pages -- **test command**: `/test-api` - -### TC-16: Imported Source with stripped credentials is flagged for re-entry -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/configuration-export-import/spec.md#requirement-req-009-imported-sources-with-redacted-credentials-are-flagged-for-re-entry` -- **type**: functional -- **persona**: Noor -- **preconditions**: import document containing a Source with no credential fields (post-REQ-005 export) -- **steps**: confirm the import; view the post-import summary -- **expected result**: `credentialsNeedingReentry` lists the Source and its missing fields, with a link to its edit form; the created Source itself has no credential values -- **test command**: `/test-functional` - -### TC-17: Catalog page manifest conformance -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/openconnector-app-manifest/spec.md#requirement-manifest-must-declare-a-catalog-page-and-menu-entry` -- **type**: regression -- **preconditions**: `src/manifest.json` updated -- **steps**: run `check:manifest` (existing `validateManifest` script per `openconnector-app-manifest` spec) -- **expected result**: validation passes with zero errors; Catalog page/menu entries present as specified -- **test command**: `/test-regression` - -### TC-18: Existing pages/routes unaffected (regression) -- **spec_ref**: `openspec/changes/connector-catalog-ui/proposal.md#impact` -- **type**: regression -- **preconditions**: full manifest with Catalog added -- **steps**: spot-check Sources, Endpoints, Jobs, Synchronizations pages and their existing actions -- **expected result**: no behavioural change to any pre-existing page -- **test command**: `/test-regression` - -### TC-19: Accessibility of catalog search/filter controls -- **spec_ref**: `openspec/changes/connector-catalog-ui/specs/connector-catalog/spec.md#non-functional-requirements` -- **type**: accessibility -- **preconditions**: Catalog page rendered -- **steps**: run automated WCAG 2.1 AA check against the search field and category filter chips -- **expected result**: no critical/serious violations; controls carry accessible labels -- **test command**: `/test-accessibility` - -## Coverage Summary - -| Requirement | Covered by | -|---|---| -| connector-catalog REQ-001 (list + filter + status badges) | TC-1, TC-2, TC-3, TC-19 | -| connector-catalog REQ-002 (Enable/Instantiate authorized action) | TC-4, TC-5, TC-6, TC-7 | -| connector-catalog REQ-003 (single registry source of truth) | TC-8, TC-9 | -| configuration-export-import REQ-006 (export from UI) | TC-10, TC-11 | -| configuration-export-import REQ-007 (import preview) | TC-12, TC-13 | -| configuration-export-import REQ-008 (confirmation required) | TC-14, TC-15 | -| configuration-export-import REQ-009 (credential re-entry flagging) | TC-16 | -| openconnector-app-manifest (Catalog page + menu entry) | TC-17, TC-18 | - -## Out of Scope - -- Load/performance testing of the O(all entities) export cost — pre-existing, documented limitation (`configuration-export-import` REQ-001 Notes), not changed or newly tested by this change. -- Testing a remote/community template marketplace — explicitly out of scope per proposal.md. -- Full environments/credential-rebinding promotion flow — deferred per proposal.md until `source-broker-credentials` lands. diff --git a/openspec/changes/environments-and-promotion/design.md b/openspec/changes/environments-and-promotion/design.md deleted file mode 100644 index d01e062f7..000000000 --- a/openspec/changes/environments-and-promotion/design.md +++ /dev/null @@ -1,349 +0,0 @@ -# Design: environments-and-promotion - -## Architecture Overview -Promotion is orchestrated entirely from the SOURCE instance. There is no new -inbound surface on the target beyond the two endpoints -`configuration-export-import` already routes (`POST -/api/configurations/import/preview`, `POST /api/configurations/import`). - -``` -[Operator on instance A] - | - v -Environments & Promotion UI (manifest-v2 page, instance A) - | - v -PromotionController (instance A) - | - v -PromotionService (instance A) - |-- 1. ConfigurationService::exportConfiguration() [unchanged, local] - |-- 2. scan export for credentialRef placeholders [new, in-process] - |-- 3. apply operator-supplied credentialBindings [new, in-process] - |-- 4. CallService::call($environmentB.sourceRef, 'POST', - | '/api/configurations/import/preview', ...) [reused dispatch] - |-- 5. merge target's preview + local credentialRef bucket -> UI - |-- 6. (on confirm) CallService::call($environmentB.sourceRef, 'POST', - | '/api/configurations/import', {confirmed:true, ...}) - |-- 7. write promotion_audit object [new, OR object] - | - v - [instance B's own ConfigurationController/ConfigurationService, - unchanged, receives the call exactly like any other API client] -``` - -`environment` objects never grant instance A code access to instance B's -database, filesystem, or OpenRegister directly — every cross-instance -interaction is an ordinary authenticated HTTP call through the SAME pipeline -a Source-to-external-API call already uses. - -## Decisions - -### Decision 1: Environment metadata is an OpenRegister object, not IAppConfig -**Chosen: new `environment` schema in the existing `openconnector` register -(OR object).** - -Rationale: -- ADR-008 / `openconnector-direct-or-usage` establish OpenRegister as the - required persistence layer for every Integriq entity; app-local - reimplementation of storage (which `IAppConfig` effectively is here — a - flat key/value store) is the pattern this change must NOT introduce. -- `IAppConfig` stores scalar key→value pairs per app; it has no native - concept of "many named rows with structured fields," no per-row RBAC, no - audit trail, and nothing analogous to OR's slug/relation `$ref` wiring - that the promotion UI needs (`environment.sourceRef` → `source` object). - Representing N named environments would require hand-rolled JSON-blob - serialisation into a single config key — exactly the kind of - app-local-reimplementation ADR-008 forbids. -- OR objects give environments a slug (consistent with every other - Integriq entity — sources, endpoints, jobs — for free), standard - CRUD via `ObjectService`, and RBAC through the same schema-lock mechanism - already used for `source` (admin-only writes, `99-source-lockdown.json` - precedent). -- Alternative considered and rejected: `IAppConfig` for a single - "environments list" JSON blob. Rejected because it duplicates OR's own - object storage, cannot be individually RBAC'd per environment, and breaks - the "every entity is persisted as an OpenRegister object" rule with no - compensating benefit — there is no performance or simplicity win over an - OR object for what is, structurally, a small list of named records. - -`environment` fields: `name`, `slug`, `role` (`source` | `target` | `both`), -`sourceRef` (uuid of a `source`-schema object describing how to reach that -environment's API), `description`. No credential material is stored on the -`environment` object itself — it lives exactly where every other Source -credential lives, behind the referenced Source's -`configuration.authentication.credentialRef`. - -### Decision 2: An environment's connectivity is a `source` object, not new plumbing -**Chosen: `environment.sourceRef` points at an ordinary `source`-schema -object (`type: "api"`), dispatched via the existing `CallService::call()`.** - -Rationale (see discovery.md): `CallService::call()` is the only outbound -HTTP path in the codebase and is hard-bound to a `source`-schema -`ObjectEntity`. `BrokeredCallService` already layers `credentialRef` proxy -resolution on top of exactly that shape. Wrapping connectivity in a `source` -object means promotion dispatch is a completely ordinary Source call: -CallLog auditing, retry policy, rate limiting, and REQ-005-style redaction -on any promotion CallLog all apply unchanged. The alternative — a new -`EnvironmentClientService` with its own Guzzle client and its own -credential-broker integration — would duplicate `BrokeredCallService` -end-to-end for no behavioural gain, violating "build ON the existing -services, do NOT fork them." - -Trade-off accepted: promotion calls appear in the Logs UI as ordinary Source -calls against a Source most operators won't otherwise interact with -directly. `promotion_audit` stores the dispatched CallLog id(s) as a -cross-reference so an operator can pivot from the audit entry to the raw -CallLog when diagnosing a failed promotion (see Decision 4). - -### Decision 3: `credentialRef` re-binding is explicit, client-computed, and never resolves a secret -**Chosen: `PromotionService` scans the exported OAS document for -`{"credentialRef": {...}}` placeholders (same shape `BrokeredCallService:: -isPlaceholder()` detects) and returns them as a new -`credentialRefsNeedingRebind` preview bucket, alongside the target's own -REQ-007 preview response. The document sent to the target is REWRITTEN -in-process to substitute each flagged `credentialRef` with an -operator-supplied replacement (`{"credentialId": "..."}` or -`{"credentialName": "..."}` valid in the TARGET environment) before the -confirmed import call — never resolved to plaintext at any point.** - -Rationale: -- `credentialRef` is, by design (`BrokeredCallService`), never resolved to a - plaintext secret anywhere except inside the broker's own constrained - proxy/injection call at actual dispatch time. Promotion must preserve that - invariant: `PromotionService` only ever reads/writes the reference SHAPE - (`credentialId`/`credentialName` strings), never a secret value. This is - the literal meaning of "re-binding, not copying." -- Rewriting happens BEFORE the document leaves instance A and is sent to - instance B — not as a post-import fixup on B — so the diff preview - (Decision 4) reflects exactly what will be written, and an unconfirmed - promotion never leaves a Source on B with a dangling reference. -- Validating that an operator-supplied replacement resolves on B happens by - delegating to B: the rewritten document is what gets sent to B's own - `/api/configurations/import/preview`; if the referenced credential doesn't - exist on B, that surfaces as a normal Source-auth failure the first time - B calls that Source — the SAME failure mode as any other misconfigured - Source, deliberately not re-invented as a new validation path. (B's - `CredentialBrokerService` is the only component that can authoritatively - answer "does this credentialId/Name exist and resolve for this owner" — - `PromotionService` on A has no visibility into B's broker state and must - not guess.) -- Alternative considered: auto-resolve by `credentialName` match only (skip - operator involvement when names match across environments). Rejected as - the default because a same-named credential on B is not guaranteed to be - the operator's intent (naming collisions, different owners) — silently - auto-binding cross-environment credentials is a lateral-movement risk. - Left as an OPT-IN convenience: the UI may pre-fill a rebind suggestion - when a `credentialName` (not `credentialId`) reference already matches a - visible name, but the operator must explicitly confirm it — never - automatic. -- Alternative considered: resolve the secret on A and re-inject it as an - embedded auth field on B. Rejected outright — this is exactly the - "copied secrets" anti-pattern the brief and `BrokeredCallService`'s "NO - fallback to embedded authentication under any circumstance" rule forbid. - -### Decision 4: Diff preview reuses the target's existing REQ-007 endpoint verbatim -**Chosen: `PromotionService` calls the target's unmodified `POST -/api/configurations/import/preview` remotely (via `CallService::call()`, -Decision 2) and merges its response with the local -`credentialRefsNeedingRebind` bucket (Decision 3). No new diff/classification -algorithm is written.** - -Rationale: `ConfigurationImportPreviewService` already computes creates/ -updates/collisions/unresolvedReferences/credentialsNeedingReentry against -whatever environment it runs in. Since promotion's target IS a different -environment, the only correct place to run that classification is ON the -target — a locally-computed diff on A would be comparing against A's own -data, not B's. Invoking B's already-tested, already-routed endpoint over -HTTP is both simpler and correct; re-implementing the same classification -logic locally (as if it read B's data over some new API) would duplicate -`ConfigurationImportPreviewService` for zero benefit and risk drift between -the two copies. - -## API Design - -### `POST /api/environments` -Create an `environment` object. Admin-only (ADR-023 `environment.manage`, -seeded `["admin"]`), plus OR data-layer authorization on the `environment` -schema. - -**Request:** -```json -{ "name": "Production", "slug": "production", "role": "target", "sourceRef": "", "description": "..." } -``` -**Response:** -```json -{ "id": "...", "uuid": "...", "slug": "production", "name": "Production", "role": "target", "sourceRef": "" } -``` - -### `GET /api/environments` -List registered environments. `environment.manage` action gate. - -### `POST /api/promotions/preview` -Non-mutating. Computes the merged diff preview (Decision 4) without writing -anything on A or B. `environment.promote` action gate. - -**Request:** -```json -{ - "configurationId": "cfg-1", - "targetEnvironmentSlug": "production", - "credentialBindings": [ - { "sourceSlug": "my-api-source", "field": "configuration.authentication.credentialRef", "credentialName": "prod-api-key" } - ] -} -``` -**Response:** -```json -{ - "creates": [], "updates": [], "collisions": [], - "unresolvedReferences": [], - "credentialsNeedingReentry": [], - "credentialRefsNeedingRebind": [ - { "type": "source", "slug": "my-api-source", "field": "configuration.authentication.credentialRef", "rebound": true } - ] -} -``` - -### `POST /api/promotions` -Confirmed promotion. Requires `confirmed: true` (mirrors REQ-008); rejects -with 400 otherwise. `environment.promote` action gate. Delegates the actual -write to the target's own `/api/configurations/import` (unchanged), then -writes a `promotion_audit` object. - -**Request:** same shape as `/api/promotions/preview` plus `"confirmed": true`. -**Response:** -```json -{ - "auditId": "...", - "written": { "sources": ["my-api-source"], "endpoints": ["..."] }, - "callLogId": "..." -} -``` - -## Database Changes -Two new OpenRegister schemas added to `lib/Settings/integriq_register.json` -(REQ-A-001/REQ-A-005 conventions from `openconnector-register-schema`): -- `environment` (mutable config schema — `appendOnly: false`, `immutable: false`): - `name`, `slug`, `role` (enum `source`|`target`|`both`), `sourceRef` (UUID, - `$ref` to `source`), `description`. -- `promotion_audit` (log schema — `appendOnly: true`, `immutable: true`, - carries `x-openregister-archival` retention matching the existing log - schemas' convention): `actorUid`, `configurationId`, `fromEnvironmentSlug`, - `toEnvironmentSlug`, `startedAt`, `completedAt`, `outcome` - (`success`|`failed`|`rejected`), `previewSummary` (counts only — no - entity payloads, no credential values), `credentialRebindCount`, - `callLogId` (cross-reference to the `CallLog` created by the underlying - `CallService::call()` dispatch, per Decision 2). - -## Nextcloud Integration -- Controllers: `PromotionController` (Controller → Service → Mapper, ADR-008). -- Services: `PromotionService` (new), reusing `ConfigurationService`, - `ConfigurationImportPreviewService`'s response SHAPE (not its code — the - actual preview call happens on the target), `CallService`, - `BrokeredCallService` (transitively, via `CallService::call()`), - `ActionAuthService`. -- Mappers/Entities: none new — `environment` and `promotion_audit` are plain - OR objects via `ObjectService`, consistent with every other Integriq - schema (no bespoke Doctrine mapper). -- Events/Hooks: none new. - -## Security Considerations -- Both new action keys (`environment.manage`, `environment.promote`) are - seeded `["admin"]` in `lib/actions.seed.json`, following the existing - `.` convention (`configuration.export`, `catalog.instantiate`). -- No secret ever crosses the promotion call: exported Sources are - REQ-005-redacted exactly as today, and `credentialRef` values are - references only (Decision 3) — `PromotionService` cannot read a plaintext - secret because it never calls the broker's resolution methods, only - `ConfigurationService`/`CallService`/`BrokeredCallService`'s existing, - unmodified entry points. -- `promotion_audit.previewSummary` stores counts and slugs only, never - entity payloads or credential values, mirroring - `BrokeredCallService::logOwnerRefusal()`'s "guard name + identity only, - never secret material" logging discipline. -- The underlying entity writes on the TARGET still pass through that - target's own OpenRegister data-layer authorization unchanged (e.g. - Source writes remain admin-only there too) — promotion does not grant any - new authority on B beyond what the environment Source's credential - already carries. -- CSRF: promotion is triggered from the UI via the standard Nextcloud - session + CSRF token flow; `#[NoCSRFRequired]` is NOT used on - `PromotionController` (unlike `ConfigurationController`'s export/import, - which accept file uploads/API-style calls) since promotion is - UI-initiated only in this change's scope. - -## NL Design System -Environments & Promotion page is a manifest-v2 `type: "index"` list page -(environment CRUD) plus a promote flow reachable from a configuration -group's existing actions. The promote flow's confirmation step is its own -`NcModal` file under `src/modals/PromotePreviewModal.vue` (never inlined — -hydra modal-isolation gate). Environment/target select uses `NcSelect` with -`inputLabel` set (hydra nc-input-labels gate). All strings ENGLISH per -project i18n convention. - -## File Structure -``` -lib/ - Controller/ - PromotionController.php - Service/ - PromotionService.php - Settings/ - integriq_register.json (add environment, promotion_audit schemas) - actions.seed.json (add environment.manage, environment.promote) -appinfo/ - routes.php (add /api/environments*, /api/promotions*) -src/ - modals/ - PromotePreviewModal.vue - views/ - EnvironmentsPromotion.vue -tests/ - Unit/Service/PromotionServiceTest.php - Integration/PromotionIntegrationTest.php -``` - -## Seed Data - -### Schema: `environment` -| Field | Object 1 | Object 2 | -|-------|----------|----------| -| slug | `local` | `acceptance` | -| name | Local | Acceptance | -| role | source | target | -| sourceRef | *(seeded `source` object, type: api, location: `https://acceptance.example.org`)* | *(same convention)* | -| description | This instance | Acceptance environment for pre-production promotion | - -**Related items per object:** none (no files/notes/tasks/contacts — environments -are configuration metadata, not content objects). - -### Schema: `promotion_audit` -No seed rows — append-only log schema, populated only by real promotions -(consistent with `call_log`/`job_log`, which also ship with zero seed rows). - -## Trade-offs -- Reusing `CallService::call()` for promotion dispatch means promotion - inherits Source-call semantics (retry, rate limit, CallLog) that were - designed for arbitrary external APIs, not specifically for - instance-to-instance Integriq calls — accepted because the - alternative (new dispatch code) duplicates a large, already-hardened - pipeline for a narrower use case. -- Diff preview requires a live round-trip to the target environment before - every promotion attempt (no offline/cached diff) — accepted because a - stale local diff could show a false "no collisions" and silently - overwrite something created on B after the last preview. -- `credentialRefsNeedingRebind` is computed by scanning the export JSON - client-side rather than by extending `ConfigurationImportPreviewService` - with this concern — accepted (per discovery.md) because REQ-007's preview - is scoped to slug/id resolution, not credential-broker semantics, and - bolting broker-awareness onto that service would blur its single - responsibility. - -## Open Questions -- Should `environment.role` (`source`/`target`/`both`) be enforced at - promotion time (reject promoting FROM an environment whose local record - has `role: target` only), or is it purely descriptive/UI-filtering? - Deferred to tasks.md implementation; default behaviour treats `role` as - UI-filtering only (any environment can technically be promoted to/from, - matching how Sources aren't role-locked today either). diff --git a/openspec/changes/environments-and-promotion/discovery.md b/openspec/changes/environments-and-promotion/discovery.md deleted file mode 100644 index 6e44bb55b..000000000 --- a/openspec/changes/environments-and-promotion/discovery.md +++ /dev/null @@ -1,103 +0,0 @@ -# Discovery: environments-and-promotion - -## Question -The context brief calls for "promote = export from environment A, import into -environment B" and "credential re-binding via the credential broker." Two -things needed verifying against HEAD before design could proceed: (1) does -Integriq have ANY existing outbound-call capability suitable for -reaching a *different* Integriq instance's API, or would promotion -require a brand-new HTTP client; and (2) does the existing export/import -pipeline already do anything with `credentialRef`-shaped values, or is -re-binding entirely new ground. - -## Approach Taken -Read `lib/Service/ConfigurationService.php`, `lib/Service/ -ConfigurationImportPreviewService.php`, `lib/Service/ConfigurationHandlers/ -SourceHandler.php`, `lib/Service/Security/SensitiveFieldRegistry.php`, -`lib/Service/BrokeredCallService.php`, `lib/Service/CallService.php` -(`call()` signature), `lib/Controller/ConfigurationController.php`, and -`appinfo/routes.php` at HEAD. Cross-checked against the `configuration-export-import` -spec (status: done) and the `source-broker-credentials` change referenced -from `BrokeredCallService`'s docblocks. - -## Findings -- **Import/preview endpoints already exist and are routed**: `POST - /api/configurations/import/preview` (REQ-007) and `POST - /api/configurations/import` (REQ-008) are live, gated by - `ActionAuthService::requireAction()` with `configuration.import`. A remote - instance can already be pushed a configuration document by any caller that - can authenticate to it — promotion does not need a new import/diff - algorithm, only a way to REACH that endpoint on the target. -- **`CallService::call(ObjectEntity $source, ...)` is the only outbound HTTP - path in the app**, and it is hard-bound to a `source`-schema `ObjectEntity` - (`$sourceData = $source->getObject()`, reads `location`, `configuration`, - drives CallLog/retry/rate-limit/redaction). There is no generic - "make an authenticated HTTP call" utility outside the Source abstraction. - `BrokeredCallService` layers `credentialRef` proxying and app-side - injection on top of exactly this same Source-shaped configuration - (`configuration.authentication.credentialRef`). -- **Consequence**: the cheapest, most reuse-faithful way to model "reach - environment B's API" is to make each `environment` object point at an - ordinary `source`-schema object (`type: "api"`, `location` = the target's - base URL, `configuration.authentication.credentialRef` = the credential - used to authenticate to it). Dispatching a promotion call then becomes a - normal `CallService::call()` invocation — CallLog auditing, retry, - rate-limiting, and `BrokeredCallService`'s broker-backed credential - resolution all apply with zero new code. Inventing a parallel - "EnvironmentClientService" with its own Guzzle client would duplicate all - of that. -- **`credentialRef` is NOT touched by the existing export/import pipeline**: - grepped `lib/Service/ConfigurationHandlers/*.php` and `SensitiveFieldRegistry.php` - for `credentialRef`/`credentialId`/`credentialName` — zero matches. - `SensitiveFieldRegistry::SECRET_NAME_PATTERN` does not match `credentialId` - or `credentialName` as key names (no substring overlap with `token|key| - secret|password|...|auth|...`), so a Source's `credentialRef` placeholder - survives export completely unredacted and unresolved — it is exported - verbatim as `{"credentialRef": {"credentialId": ""}}` (or - `credentialName`). REQ-004's id↔slug translation also does not touch it - (its vocabulary is `targetId`/`sourceId`/`inputMapping`/`outputMapping`/ - `rules[]`/nested `Id` keys — not `authentication`). This means a - naive promotion today would carry a source-environment-specific - credential UUID straight into the target environment, where it almost - certainly does not resolve to any credential (broker credentials are - per-instance/per-owner) — silently breaking the Source on first use rather - than failing loudly at promotion time. -- **REQ-009's `credentialsNeedingReentry` is a different, narrower thing**: - it flags the top-level fields `SourceHandler::export()` strips outright - (`apikey`, `secret`, `username`, `password`, `jwt`, ...) — it says nothing - about a `credentialRef` placeholder, because that field isn't stripped at - all (it's not in `SourceHandler`'s `unset()` list and doesn't match - `SensitiveFieldRegistry`). This change needs its own, - additional classification bucket for `credentialRef` re-binding; it cannot - reuse REQ-009's bucket as-is. - -## Recommendation -Model `environment` as OR-object metadata that WRAPS an existing `source` -object rather than inventing new connectivity plumbing, and reuse -`CallService::call()` (with its existing `BrokeredCallService` credentialRef -proxy path) to dispatch promotion's remote preview/import calls. Reuse the -target's existing `/api/configurations/import/preview` and `/api/ -configurations/import` endpoints unchanged. Add a dedicated, -promotion-specific `credentialRefsNeedingRebind` preview bucket — computed -client-side in the new `PromotionService` by scanning the exported document -for `credentialRef` placeholders (same detection logic as -`BrokeredCallService::containsPlaceholder()`/`isPlaceholder()`, applied to -already-exported JSON rather than a live source object) — since neither -REQ-004's translation nor REQ-009's reentry flag covers this case. - -## Risks Uncovered -- If the target environment's `source`-schema object (the one an - `environment` wraps) is itself misconfigured (wrong `location`, expired - credentialRef), promotion calls fail the same way any broken Source call - fails today (synthetic 409/403/502 CallLog) — acceptable and already - well-tested behaviour, not a new failure mode to design around. -- `CallService::call()` persists a `CallLog` for every promotion dispatch. - This is desirable (existing infra for free) but means promotion preview - and import calls are visible in the Logs UI as regular Source calls, not - labelled as "promotion" calls unless the `promotion_audit` object - cross-references the CallLog id — design.md should decide whether to - store that cross-reference. - -## Next Steps -Proceed to design.md and specs with the environment-wraps-a-Source model and -the promotion-specific credentialRef rebind bucket as settled decisions. diff --git a/openspec/changes/environments-and-promotion/migration.md b/openspec/changes/environments-and-promotion/migration.md deleted file mode 100644 index bfe456b67..000000000 --- a/openspec/changes/environments-and-promotion/migration.md +++ /dev/null @@ -1,99 +0,0 @@ -# Migration: environments-and-promotion - -## Current State -The `openconnector` OpenRegister register (`lib/Settings/integriq_register.json`, -per `openconnector-register-schema`) declares 15 schemas (`source`, -`endpoint`, `mapping`, `rule`, `job`, `synchronization`, the 4 log schemas, -etc.) but has no `environment` or `promotion_audit` schema. There are no -`oc_openconnector_environment*` or `oc_openconnector_promotion*` SQL tables -— every Integriq entity is a generic OpenRegister object, not a -bespoke table, so this change adds NO new PostgreSQL tables or columns of -its own. - -## Target State -Two new schemas exist inside the `openconnector` register: -- `environment` (mutable config schema): `name`, `slug`, `role` - (`source`|`target`|`both`), `sourceRef` (UUID `$ref` → `source`), - `description`. -- `promotion_audit` (append-only, immutable log schema, matching - `call_log`/`job_log`'s `appendOnly: true`/`immutable: true` + - `x-openregister-archival` retention convention): `actorUid`, - `configurationId`, `fromEnvironmentSlug`, `toEnvironmentSlug`, - `startedAt`, `completedAt`, `outcome`, `previewSummary`, - `credentialRebindCount`, `callLogId`. - -Both are added via a per-change register fragment -(`lib/Settings/register.d/environments-and-promotion.json`, an OpenAPI -`components.schemas` fragment), per ADR-037's "each change adds its own -`.json` instead of editing `integriq_register.json`" rule -(`lib/Settings/register.d/README.md`) — the same mechanism every other -recent Integriq schema addition uses (e.g. -`register.d/eudi-wallet-credential-issuance.json`). - -## Migration Class -**This change does NOT introduce a Nextcloud `IMigrationStep`/`changeSchema()` -class.** Integriq's schema additions are NOT applied via the standard -Nextcloud DB-migration framework — verified against HEAD -(`lib/Repair/InitializeRegister.php`): the register descriptor + its -`register.d/*.json` fragments are imported into OpenRegister via OR's own -`ConfigurationService::importFromApp()`, invoked by the `InitializeRegister` -`IRepairStep` wired in `appinfo/info.xml` under both `` and -``. This exists specifically because a `postSchemaChange()` -Nextcloud migration runs before peer apps' (OpenRegister's) autoloaders are -guaranteed available on a fresh `occ app:enable`, whereas an `IRepairStep` -runs after all enabled apps are bootstrapped. - -``` -No Version*.php migration class. Schema delivery mechanism: -File: lib/Settings/register.d/environments-and-promotion.json (new) -Repair step: lib/Repair/InitializeRegister.php (existing, unmodified — already - wired to import every register.d/*.json fragment; requires no code change, - only the new fragment file) -Idempotency: OR's importFromApp() short-circuits on the descriptor's `version` - field, exactly as it does today for the other 15 schemas. -``` - -## Migration Steps -1. Add `lib/Settings/register.d/environments-and-promotion.json` declaring - the `environment` and `promotion_audit` schemas (OpenAPI - `components.schemas` + `x-openregister` annotations), following the - existing fragment format (see any file already under `register.d/`). -2. No change to `InitializeRegister.php` — it already merges every - `register.d/*.json` fragment into the register descriptor before calling - `importFromApp()`. -3. On `occ app:enable integriq` (fresh install) or `occ upgrade` - (existing install), the repair step runs automatically and creates the - two new schemas inside the existing `openconnector` register — no - separate register is created. -4. Seed data (design.md's Seed Data section: two `environment` objects, - `local` and `acceptance`, each referencing a seeded `source` object) is - created the same way existing seed objects are — via the app's existing - seed-loading path (`lib/sources.seed.json`-style convention), not a - migration step. - -## Data Impact -Zero rows affected on existing schemas — this migration is purely additive -(two new, initially-empty schemas). No existing `source`, `endpoint`, -`mapping`, `rule`, `job`, or `synchronization` object is read, written, or -reshaped. Safe to run on live data; `promotion_audit` and `environment` -start empty (aside from seed data) on every install. - -## Rollback Procedure -Remove `lib/Settings/register.d/environments-and-promotion.json` and -redeploy. The two schemas remain declared inside OpenRegister (OR does not -retroactively delete schemas on a descriptor rollback) but become -unreachable from the Integriq UI/API once the routes and controller -are also rolled back (this change is deployed as one unit — see -proposal.md's Rollback Strategy). No SQL rollback is needed since no SQL -schema changed. - -## Validation -- `occ app:enable integriq` on a fresh instance completes without - error and `InitializeRegister`'s repair-step log line reports the - `environment` and `promotion_audit` schemas among the imported set. -- `GET /api/environments` (new) returns the two seeded environments - (`local`, `acceptance`) with `HTTP 200` on a fresh install. -- Creating a `promotion_audit` object directly via the OpenRegister object - API and then attempting to `PUT`/`DELETE` it fails with OR's - `appendOnly`/`immutable` enforcement, identically to an existing - `call_log` object. diff --git a/openspec/changes/environments-and-promotion/proposal.md b/openspec/changes/environments-and-promotion/proposal.md index cf3065fe7..10953d364 100644 --- a/openspec/changes/environments-and-promotion/proposal.md +++ b/openspec/changes/environments-and-promotion/proposal.md @@ -1,153 +1,41 @@ -# Proposal: environments-and-promotion - -## Summary -This change adds first-class named environments (e.g. staging, production) and a -promotion workflow to Integriq: promoting a configuration group means -exporting it from the local instance via the existing `ConfigurationService` -and pushing it into a registered target environment's existing import -endpoints, with a pre-promotion diff preview, explicit credential re-binding -(never secret copying) via the OpenRegister credential broker, and an -append-only promotion audit log. It builds entirely on the already-merged -configuration export/import substrate (slug translation, credential -redaction) and the credential broker (`source-broker-credentials`) — nothing -in either is forked. - -## Motivation -n8n gates environment promotion behind its paid Enterprise tier; Workato -sells this as "Recipe Lifecycle Management." Integriq already has the -hard parts — slug-referenced export/import, credential redaction, an import -preview endpoint, and a `credentialRef`-based credential broker — but no -concept of a *named* target environment, no automated push between -environments, no pre-promotion diff, and no audit trail of who promoted what, -from where, to where, and when. Shipping this open under EUPL is a -Common-Ground procurement wedge: government customers evaluating n8n/Workato -alternatives can get environment promotion without an enterprise license. -Codeberg issue #155. - -## Affected Projects -- [x] Project: `integriq` — new `environment` and `promotion_audit` - OpenRegister schemas, `PromotionService`, `PromotionController` + routes, - new `environment.manage` / `environment.promote` ADR-023 action keys, and - an Environments & Promotion manifest-v2 UI page. - -## Scope - -### In Scope -1. An `environment` OpenRegister object schema (name, slug, role, and a - `sourceRef` pointing at an existing `source`-schema object of - `type: "api"` that describes how to reach that environment's Integriq - API — reusing the Source schema's existing `location` + - `configuration.authentication.credentialRef` shape instead of inventing a - new connection-descriptor format). -2. A `PromotionService` that: (a) calls the existing, unmodified - `ConfigurationService::exportConfiguration()` locally; (b) dispatches the - exported document to the target environment's existing, unmodified - `POST /api/configurations/import/preview` and `POST - /api/configurations/import` endpoints (REQ-007/REQ-008) via the existing - `CallService::call()` outbound pipeline, using the target environment's - `sourceRef` Source — so retry, rate-limiting, CallLog auditing, and - `credentialRef` broker resolution for reaching the target are all reused - unchanged, not reimplemented. -3. Explicit credential re-binding: any `Source` in the exported document whose - `configuration.authentication` carries a `credentialRef` placeholder is - surfaced by the preview as needing an operator-supplied re-binding - (`credentialId`/`credentialName` valid in the TARGET environment's broker) - before the promotion is confirmed. `credentialRef` values are never - resolved to plaintext and never copied between environments — only the - reference is rewritten. -4. A diff preview step before promotion, reusing the target environment's - existing import-preview response (creates/updates/collisions/unresolved - references/credentials-needing-reentry) plus a promotion-specific - `credentialRefsNeedingRebind` bucket computed client-side from the - exported document. -5. An append-only, immutable `promotion_audit` OpenRegister object schema - (who, configuration id, from-environment, to-environment, timestamp, - preview summary, outcome) written after every promotion attempt, - following the same `appendOnly`/`immutable` convention as the existing - `call_log`/`job_log` schemas. -6. An Environments & Promotion manifest-v2 UI page: environment CRUD list and - a promote flow (select configuration group → select target environment → - review diff + credential rebind prompts → confirm). -7. Unit tests for environment metadata and credential-rebind resolution; - integration tests exporting from environment A and importing into - environment B, asserting `credentialRef`s are re-bound, not copied as - secrets. - -### Out of Scope -- Git-backed configuration storage / GitOps workflows — a follow-up change. -- Automatic, unattended promotion (e.g. on a schedule or CI trigger) — this - change is operator-confirmed only, matching REQ-008's existing - confirmation requirement. -- Multi-hop promotion chains (A→B→C in one operation) — one promotion is - always a single source→target pair. - -## Approach -Reuse, don't fork. Environment connectivity is modelled as an existing -`source`-schema object so the existing `CallService`/`BrokeredCallService` -dispatch pipeline (auth, retry, CallLog, redaction) carries promotion traffic -without new HTTP client code. The diff preview is the existing target-side -`/api/configurations/import/preview` endpoint, invoked remotely instead of -in-process — no new diff algorithm. Credential re-binding is a thin -preprocessing/postprocessing layer in `PromotionService` around the -unmodified export/import pipeline: it never touches `ConfigurationHandlers` -or `SensitiveFieldRegistry`. See design.md for the full architecture and the -credential-rebinding decision. - -## New Dependencies -None — reuses `ConfigurationService`, `ConfigurationImportPreviewService`, -`CallService`, `BrokeredCallService`, and OpenRegister's -`CredentialBrokerService`, all already present. - -## Impact -- New: `lib/Service/PromotionService.php`, `lib/Controller/PromotionController.php`, - `lib/Settings/integriq_register.json` additions (`environment`, - `promotion_audit` schemas), `lib/actions.seed.json` additions, `appinfo/routes.php` - additions, a new manifest-v2 page + Vue components under `src/`. -- Unchanged: `ConfigurationService`, `ConfigurationHandlers/*`, - `ConfigurationImportPreviewService`, `SensitiveFieldRegistry`, - `BrokeredCallService`, `CallService`. - -## Cross-Project Dependencies -Depends on OpenRegister's `CredentialBrokerService` (already a hard runtime -dependency per `openconnector-direct-or-usage`) for resolving a target -environment's connection credential and for validating operator-supplied -credential re-bindings. No other apps consume this change. - -## Risks - -### Risk 1: Target environment API version skew -**Severity:** Medium — **Mitigation:** `PromotionService` calls the target's -`/api/configurations/import/preview` and `/api/configurations/import` -endpoints exactly as documented in `configuration-export-import` (REQ-007/ -REQ-008); a target running an older Integriq without those routes -returns 404, surfaced to the operator as a promotion failure with an -actionable message, not a silent partial write. - -### Risk 2: Operator promotes with an unresolved credentialRef -**Severity:** Medium — **Mitigation:** the diff preview's -`credentialRefsNeedingRebind` bucket is a blocking warning; `import` on the -target still enforces REQ-008's `confirmed: true` gate, and an unrebound -`credentialRef` that does not resolve on the target simply fails at the -target's own Source-auth guard (`BrokeredCallService`) the first time that -Source is used — never at promotion time with a leaked secret, because no -secret ever transits the promotion call. - -### Risk 3: Promotion audit log grows unbounded -**Severity:** Low — **Mitigation:** `promotion_audit` follows the existing -log-schema retention convention (`x-openregister-archival`), matching -`call_log`/`job_log`. - -## Rollback Strategy -The new schemas, service, controller, routes, and UI page are additive. To -roll back, remove the routes and hide the manifest page; the `environment` -and `promotion_audit` OpenRegister objects remain harmless, inert data. -`ConfigurationService` and `CredentialBrokerService` are never modified, so -rollback carries zero risk to existing export/import or brokered-call -functionality. - -## Open Questions -- Should a promotion be retryable/resumable if the target import partially - succeeds (e.g. sources written, endpoints fail)? Deferred to design.md; - current default follows `importConfiguration()`'s existing per-type - best-effort behaviour (unchanged), recorded as a known limitation in the - audit entry rather than solved with new rollback machinery. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: environments-and-promotion (superseded — retired 2026-09-02) + +This directory double-counted a change that had already shipped. The +environments and promotion feature was implemented and archived on +2026-07-15 (`archive/2026-07-15-environments-and-promotion`, 25/37 tasks +checked with per-task evidence), yet this live copy was resurrected at +0/35: the openconnector→integriq rename applied to the prose, the evidence +notes stripped, every box reset. The machinery exists at HEAD: +`lib/Controller/EnvironmentController.php`, +`lib/Controller/PromotionController.php`, +`lib/Service/EnvironmentService.php`, `lib/Service/PromotionService.php`, +the `lib/Settings/register.d/environments-and-promotion.json` fragment, the +manifest-declared Environments page +(`src/manifest.d/environments-and-promotion.json`, page id +`src-environments`), `src/modals/PromotePreviewModal.vue`, and the +environment/promotion routes in `appinfo/routes.php`. + +No live `@spec` tags point into this directory. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `environment` schema, promotion service with preview/confirm, environment + promotion controllers and routes, Environments page (manifest fragment), promote-preview modal | **Already shipped and archived**: `archive/2026-07-15-environments-and-promotion` (25/37 boxes checked), code at HEAD | +| Residual verification: live `occ` install/fragment-merge run, Newman for `/api/environments*` and `/api/promotions*`, browser tests, feature docs, screenshot | Open, and honestly unticked in the archived twin (no live instance in that session; each open box carries its reason). Same shape as `approvals-verification-pack`; pick up in a verification pass, not by resurrecting this change | + +## Sequencing + +Nothing remains to implement from this change directly. The residual +live-instance verification and docs belong to a verification-pack-style +follow-up. + +## Archival + +This directory is retired in place (not moved or renamed) to keep the diff +reviewable; archive it via the normal flow at the next sweep. diff --git a/openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md b/openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md deleted file mode 100644 index a80d59cd2..000000000 --- a/openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md +++ /dev/null @@ -1,51 +0,0 @@ -# configuration-export-import Specification (delta: environments-and-promotion) - -## ADDED Requirements - -### Requirement: credentialRef authentication placeholders pass through export and import unresolved and untranslated (REQ-010) - -The system SHALL export and import a Source's -`configuration.authentication.credentialRef` placeholder (the reference -shape `{"credentialId": ""}` or `{"credentialName": ""}` used by -the credential broker per `http-call-engine`'s brokered-dispatch -requirements) byte-for-byte unchanged: `SensitiveFieldRegistry::redactArray()` -SHALL NOT redact the `credentialId` or `credentialName` leaf keys (neither -matches `SECRET_NAME_PATTERN` nor `EXACT_MATCH_NAMES`), and REQ-004's -id↔slug translation SHALL NOT rewrite them (its reference-field vocabulary — -`targetId`/`sourceId`/`inputMapping`/`outputMapping`/`rules[]`/nested -`Id` keys — does not include `authentication`). An exported document's -`credentialRef` therefore always carries the SOURCE environment's own -credential id or name verbatim; it is the responsibility of any consumer -that moves the document between environments (see the -`environments-and-promotion` capability) to re-bind it before or during -import into a different environment — `ConfigurationService` and its -handlers themselves perform no environment-awareness or rebinding. - -Notes: `ConfigurationImportPreviewService::missingCredentialFields()` -(REQ-009) checks only the fixed `CREDENTIAL_FIELDS` list -(`apikey`/`secret`/`username`/`password`/`jwt`/`authorizationHeader`/ -`authenticationConfig`) and has no awareness of `credentialRef` — a -credentialRef-authenticated Source, which never had any of those fields to -begin with, is therefore always reported as "needs re-entry" for all of -them even though nothing was stripped from it. This is a pre-existing, -narrow imprecision in REQ-009's classification (harmless: the operator -re-checks a Source that in fact needs no re-entry) and is not changed by -this requirement; it is recorded here because `environments-and-promotion` -introduces the correctly-scoped `credentialRefsNeedingRebind` classification -specifically to avoid relying on REQ-009 for this case. - -#### Scenario: A Source's credentialRef is not redacted on export -- GIVEN a Source whose `configuration` contains `{"authentication": {"credentialRef": {"credentialId": "550e8400-e29b-41d4-a716-446655440000"}}}` -- WHEN the Source is exported via `SourceHandler::export()` -- THEN the exported `configuration.authentication.credentialRef.credentialId` value is unchanged (`550e8400-e29b-41d4-a716-446655440000`), not `***REDACTED***` - -#### Scenario: Importing a credentialRef that does not resolve on the target does not block the write -- GIVEN an OAS document containing a Source with `configuration.authentication.credentialRef.credentialId` set to a UUID that does not correspond to any credential broker entry on the importing environment -- WHEN the document is imported via `importConfiguration()` -- THEN the Source object is created or updated exactly as REQ-003 describes, with the `credentialRef` value written verbatim -- AND no exception is thrown at import time — the dangling reference only surfaces later, when that Source is actually dispatched and `BrokeredCallService` fails to resolve the credential - -#### Scenario: credentialRef translation is absent from the id/slug mapping vocabulary -- GIVEN a Source whose `configuration.authentication.credentialRef.credentialName` is set to `"prod-api-key"` -- WHEN the Source is exported and then imported into an environment where a `source`-type or `register`/`schema` slug map entry happens to also be named `"prod-api-key"` -- THEN the `credentialRef.credentialName` value is NOT rewritten by REQ-004's translation (it is not a member of the translated field set), and remains the literal string `"prod-api-key"` on both export and import diff --git a/openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md b/openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md deleted file mode 100644 index bcba724f1..000000000 --- a/openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -status: planned ---- - -# environments-and-promotion Specification - -**Status**: planned -**Scope**: integriq -**OpenSpec changes**: -- environments-and-promotion - -## Purpose - -Integriq's `configuration-export-import` capability already moves a -coherent set of Sources/Endpoints/Mappings/Rules/Jobs/Synchronizations -between instances via a slug-referenced, credential-redacted OAS document, -and `source-broker-credentials` already lets a Source authenticate through a -`credentialRef` resolved by the OpenRegister credential broker instead of an -embedded secret. Neither capability names *environments* as first-class -objects, neither automates the export-then-import round trip between two -named environments, and neither surfaces a diff before writing or an audit -trail of who promoted what, from where, to where, and when. This capability -adds named `environment` metadata, a `PromotionService` that reuses -`ConfigurationService`, `ConfigurationImportPreviewService`'s existing -routed endpoints, and `CallService`/`BrokeredCallService`'s existing -dispatch pipeline to push a configuration group from one environment into -another, with mandatory diff preview, explicit credential re-binding -(references only, never secrets), and an append-only promotion audit log. -See `configuration-export-import` (REQ-001–REQ-010) and -`source-broker-credentials`'s `http-call-engine` delta for the underlying, -unmodified primitives this capability builds on. - -## ADDED Requirements - -### Requirement: Named environments are OpenRegister objects that wrap an existing Source for connectivity (REQ-001) - -The system SHALL persist named environments as `environment`-schema -OpenRegister objects in the `openconnector` register (`name`, `slug`, -`role` of `source`, `target`, or `both`, `description`, `sourceRef`). The -system SHALL NOT store environment connectivity as a new credential format, -a new HTTP client configuration, or an `IAppConfig` value: `sourceRef` -SHALL reference an existing `source`-schema object (`type: "api"`) whose -`location` and `configuration.authentication.credentialRef` describe how to -reach that environment's Integriq API, so that dispatching a call to an -environment reuses `CallService::call()` and, when the referenced Source -carries a `credentialRef`, `BrokeredCallService`'s existing broker -resolution — unchanged and unforked. - -#### Scenario: Creating an environment requires an existing Source reference -- GIVEN an operator with the `environment.manage` action permission -- WHEN they create an `environment` object with `slug: "acceptance"`, `role: "target"`, and `sourceRef` pointing at an existing `type: "api"` Source -- THEN the `environment` object is created in the `openconnector` register -- AND no new credential or connection material is stored on the `environment` object itself - -#### Scenario: An environment without a resolvable sourceRef cannot be used as a promotion target -- GIVEN an `environment` object whose `sourceRef` no longer resolves to an existing Source -- WHEN an operator attempts to preview or confirm a promotion to that environment -- THEN the request is rejected with an actionable error naming the missing `sourceRef` -- AND no export or remote call is attempted - -### Requirement: Promotion exports locally, unchanged, and dispatches to the target's existing import endpoints (REQ-002) - -The system SHALL implement promotion as: (1) calling the existing, unmodified -`ConfigurationService::exportConfiguration()` on the local instance to -produce the OAS document for the requested configuration id; (2) dispatching -that document to the target environment's own, unmodified `POST -/api/configurations/import/preview` (preview) or `POST -/api/configurations/import` (confirmed) endpoint via `CallService::call()` -against the target environment's `sourceRef` Source. The system SHALL NOT -reimplement export, slug translation, or redaction logic inside the -promotion path — `ConfigurationService` and its handlers remain the single -source of truth for both. - -#### Scenario: Promotion reuses the unmodified export pipeline -- GIVEN a configuration group `cfg-1` containing one Source and one Endpoint -- WHEN an operator promotes `cfg-1` from the local environment to a registered target environment -- THEN the system calls `ConfigurationService::exportConfiguration('cfg-1')` unchanged to build the document -- AND the redaction and slug-translation behaviour documented in `configuration-export-import` (REQ-001–REQ-005) applies identically to a promotion export as to a manual UI export - -#### Scenario: Promotion dispatch reuses CallService against the target's environment Source -- GIVEN a target environment whose `sourceRef` Source has `location: "https://acceptance.example.org"` -- WHEN a promotion is confirmed -- THEN the system dispatches the import call via `CallService::call()` using that Source -- AND the resulting `CallLog` is created exactly as it would be for any other Source call against that Source - -### Requirement: Diff preview merges the target's existing preview response with a credential-rebind classification (REQ-003) - -The system SHALL, before any promotion write occurs, retrieve a preview by -calling the target environment's existing `POST -/api/configurations/import/preview` endpoint (`configuration-export-import` -REQ-007, unmodified) with the exported document, and SHALL merge that -response with a `credentialRefsNeedingRebind` array computed locally by -scanning the exported document for `{"credentialRef": {...}}` placeholders -(REQ-004 below). The system SHALL NOT compute creates/updates/collisions or -unresolved slug references itself — that classification SHALL always come -from the target environment's own preview response, since only the target -knows its own object state. - -#### Scenario: Preview reflects the target's own creates/updates/collisions classification -- GIVEN a Source in the export document whose slug already exists on the target environment, and a second Source whose slug does not -- WHEN the promotion preview is requested -- THEN the response's `updates` array contains the first Source and `creates` contains the second, exactly as the target's own `/api/configurations/import/preview` response would classify them - -#### Scenario: Preview is required before a promotion can be confirmed -- GIVEN a valid configuration id and target environment -- WHEN an operator attempts to confirm a promotion without having first retrieved a preview in the same request flow -- THEN the system still computes the preview internally as part of the confirm call (mirroring REQ-005's `confirmed: true` requirement) before dispatching the write, so a promotion can never write without an equivalent preview having been computed - -### Requirement: credentialRef placeholders are re-bound per target environment, never resolved to a secret (REQ-004) - -The system SHALL detect every `{"credentialRef": {"credentialId": ...}}` or -`{"credentialRef": {"credentialName": ...}}` placeholder inside a promoted -Source's `configuration.authentication` (the same shape -`BrokeredCallService::isPlaceholder()` detects) and SHALL list each one under -the preview's `credentialRefsNeedingRebind` array, naming the Source slug and -field path. The system SHALL rewrite a flagged placeholder in the outgoing -document only when the operator supplies an explicit replacement reference -(`credentialId` or `credentialName` valid on the target) as part of the -promotion request; an un-rebound placeholder SHALL be sent to the target -verbatim (carrying the source environment's own reference), never silently -dropped or defaulted. The system SHALL NOT, at any point during promotion, -call any credential-broker method that returns a plaintext secret value — -re-binding SHALL operate on reference strings only. - -#### Scenario: A Source's credentialRef is flagged for rebinding -- GIVEN a Source in the export document with `configuration.authentication.credentialRef.credentialId` set to a UUID from the source environment's credential broker -- WHEN the promotion preview is computed -- THEN the response's `credentialRefsNeedingRebind` array contains that Source's slug and the field `configuration.authentication.credentialRef` - -#### Scenario: An operator-supplied rebinding replaces the reference before the target ever sees the original -- GIVEN the promotion request includes `credentialBindings: [{"sourceSlug": "my-api-source", "credentialName": "prod-api-key"}]` for a flagged Source -- WHEN the promotion is confirmed -- THEN the document dispatched to the target environment's import endpoint contains `configuration.authentication.credentialRef.credentialName = "prod-api-key"` for that Source, not the original source-environment credentialId -- AND at no point does the system read or transmit the plaintext secret behind either reference - -#### Scenario: An un-rebound credentialRef is sent verbatim, not resolved or dropped -- GIVEN a flagged Source with no corresponding entry in the promotion request's `credentialBindings` -- WHEN the promotion is confirmed -- THEN the document dispatched to the target contains the original, unmodified `credentialRef` value -- AND the target's own Source-auth guard (not the promotion path) is what eventually fails when that Source is eventually called against a credential that does not exist on the target - -### Requirement: Promotion requires explicit confirmation and the same action-matrix authorization as export/import (REQ-005) - -The system SHALL require `confirmed: true` on the confirm request and SHALL -reject the request with HTTP 400 if absent, mirroring -`configuration-export-import` REQ-008. Both the preview and confirm -promotion endpoints SHALL be gated by `ActionAuthService::requireAction()` -with a new `environment.promote` action key seeded `["admin"]` in -`lib/actions.seed.json`; environment CRUD endpoints SHALL be gated by a -separate `environment.manage` action key, also seeded `["admin"]`. - -#### Scenario: Promotion without confirmation is rejected -- GIVEN a valid configuration id and target environment -- WHEN the confirm endpoint is called with `confirmed` omitted or `false` -- THEN the response is HTTP 400 -- AND no export is dispatched to the target and no `promotion_audit` object is written - -#### Scenario: A user without the environment.promote action permission cannot promote -- GIVEN a non-admin user whose groups are not mapped to `environment.promote` in the action matrix -- WHEN that user calls the promotion preview or confirm endpoint -- THEN the request is rejected with `OCSForbiddenException` before any export or remote call occurs -- @e2e exclude API-level action-matrix denial — covered by PHPUnit `PromotionControllerTest::testPromoteDeniedForUnmappedNonAdmin` - -### Requirement: Every promotion attempt is recorded in an append-only promotion audit log (REQ-006) - -The system SHALL write one `promotion_audit` OpenRegister object per -confirmed promotion attempt (success or failure), recording the acting -user, the configuration id, the source and target environment slugs, start -and completion timestamps, the outcome, a preview summary (counts and -slugs only — never entity payloads or credential values), the number of -credential rebindings applied, and the id of the `CallLog` created by the -underlying dispatch. The `promotion_audit` schema SHALL be declared -`appendOnly: true` and `immutable: true`, following the same convention as -the existing `call_log`/`job_log` schemas. - -#### Scenario: A successful promotion is audited -- GIVEN a confirmed promotion of `cfg-1` from `local` to `acceptance` that writes two Sources and one Endpoint -- WHEN the promotion completes -- THEN a `promotion_audit` object is created with `outcome: "success"`, `fromEnvironmentSlug: "local"`, `toEnvironmentSlug: "acceptance"`, and a `previewSummary` reflecting the two creates/updates -- AND the object contains no credential values or full entity payloads - -#### Scenario: A failed promotion is still audited -- GIVEN a confirmed promotion whose dispatch to the target fails (e.g. the target returns 404 because it runs an older Integriq without the import routes) -- WHEN the promotion attempt completes -- THEN a `promotion_audit` object is created with `outcome: "failed"` and a message identifying the failure -- AND no partial `written` summary is fabricated — only what the target actually confirmed, if anything, is recorded - -#### Scenario: promotion_audit objects cannot be edited or deleted after creation -- GIVEN an existing `promotion_audit` object -- WHEN any caller attempts to update or delete it via the OpenRegister object API -- THEN the write is rejected by OpenRegister's `appendOnly`/`immutable` schema enforcement, identically to how `call_log`/`job_log` objects are protected today - -## Non-Functional Requirements - -- **Performance:** A promotion preview SHALL complete within the same order - of magnitude as a manual export (REQ-001's documented O(all entities of - each type) cost) plus one additional network round trip to the target - environment; no new O(n²) behaviour is introduced. -- **Accessibility:** The Environments & Promotion UI page's environment and - target selects MUST use `NcSelect` with an explicit `inputLabel` (WCAG - 2.1 AA 1.3.1/4.1.2), and the promotion confirmation flow MUST live in its - own `NcModal` file, never inlined in a parent component. -- **Internationalization:** All UI strings and API error messages MUST be - in English, matching the project-wide i18n-keys-English convention. - -## Acceptance Criteria - -- [ ] `environment` objects can be created, listed, and reference an - existing `source`-schema object via `sourceRef` -- [ ] A promotion preview merges the target's own REQ-007 response with a - `credentialRefsNeedingRebind` bucket computed locally -- [ ] A `credentialRef` placeholder is never resolved to a plaintext secret - anywhere in the promotion path -- [ ] A confirmed promotion without `confirmed: true` is rejected with 400 -- [ ] Every confirmed promotion attempt (success or failure) produces exactly - one immutable `promotion_audit` object - -## Notes - -- This capability intentionally does NOT modify `ConfigurationService`, - `ConfigurationHandlers/*`, `ConfigurationImportPreviewService`, - `SensitiveFieldRegistry`, `CallService`, or `BrokeredCallService` — see - design.md Decisions 1-4 for the reuse rationale. -- `configuration-export-import` REQ-009's `credentialsNeedingReentry` - bucket and this capability's `credentialRefsNeedingRebind` bucket are - deliberately distinct: REQ-009 covers top-level fields - `SourceHandler::export()` strips outright; this capability covers - `credentialRef` reference placeholders, which are never stripped (see the - `configuration-export-import` delta in this change, REQ-010). -- Git-backed configuration storage / GitOps is explicitly out of scope - (proposal.md) and may build on `environment` metadata in a future change. diff --git a/openspec/changes/environments-and-promotion/tasks.md b/openspec/changes/environments-and-promotion/tasks.md index d7e3f2c9c..7473fae33 100644 --- a/openspec/changes/environments-and-promotion/tasks.md +++ b/openspec/changes/environments-and-promotion/tasks.md @@ -1,135 +1,9 @@ -# Tasks: environments-and-promotion - -## Implementation Tasks - -### Task 1: Declare the environment and promotion_audit schemas via a register.d fragment -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-named-environments-are-openregister-objects-that-wrap-an-existing-source-for-connectivity-req-001` -- **files**: `lib/Settings/register.d/environments-and-promotion.json` -- **acceptance_criteria**: - - GIVEN a fresh `occ app:enable integriq` WHEN `InitializeRegister` runs THEN the `environment` and `promotion_audit` schemas exist in the `openconnector` register - - GIVEN the descriptor fragment WHEN inspected THEN `promotion_audit` declares `appendOnly: true` and `immutable: true`, matching `call_log`/`job_log` -- [ ] Implement -- [ ] Test - -### Task 2: Seed local + acceptance environment objects and their connectivity Sources -- **spec_ref**: `openspec/changes/environments-and-promotion/design.md#seed-data` -- **files**: `lib/environments.seed.json` (new, following `lib/sources.seed.json` convention) -- **acceptance_criteria**: - - GIVEN a fresh install WHEN seed data loads THEN `local` and `acceptance` `environment` objects exist, each with a `sourceRef` pointing at a seeded `type: api` Source -- [ ] Implement -- [ ] Test - -### Task 3: Environment CRUD service, controller, routes, and action keys -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-named-environments-are-openregister-objects-that-wrap-an-existing-source-for-connectivity-req-001` -- **files**: `lib/Service/EnvironmentService.php`, `lib/Controller/EnvironmentController.php`, `appinfo/routes.php`, `lib/actions.seed.json` -- **acceptance_criteria**: - - GIVEN an operator with `environment.manage` WHEN they `POST /api/environments` with a valid `sourceRef` THEN the object is created - - GIVEN an `environment` whose `sourceRef` does not resolve WHEN it is used as a promotion target THEN the request is rejected with an actionable error naming the missing `sourceRef` - - GIVEN a non-admin user without `environment.manage` WHEN they call any environment endpoint THEN `OCSForbiddenException` is returned -- [ ] Implement -- [ ] Test - -### Task 4: PromotionService — local export + credentialRef placeholder scan -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-exports-locally-unchanged-and-dispatches-to-the-targets-existing-import-endpoints-req-002` -- **files**: `lib/Service/PromotionService.php` -- **acceptance_criteria**: - - GIVEN a configuration id WHEN `PromotionService::export()` runs THEN it calls `ConfigurationService::exportConfiguration()` unchanged and returns its document verbatim - - GIVEN an exported document containing a Source with `configuration.authentication.credentialRef` WHEN scanned THEN each placeholder is detected using the same shape `BrokeredCallService::isPlaceholder()` checks -- [ ] Implement -- [ ] Test - -### Task 5: PromotionService — credential rebinding rewrite (reference-only, never plaintext) -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-credentialref-placeholders-are-re-bound-per-target-environment-never-resolved-to-a-secret-req-004` -- **files**: `lib/Service/PromotionService.php` -- **acceptance_criteria**: - - GIVEN a `credentialBindings` entry for a flagged Source WHEN the document is rewritten THEN the target document's `credentialRef` is replaced with the supplied `credentialId`/`credentialName`, never resolved to plaintext - - GIVEN no `credentialBindings` entry for a flagged Source WHEN the document is rewritten THEN the original `credentialRef` is sent verbatim, not dropped or defaulted - - GIVEN this task's code WHEN reviewed THEN it never calls `CredentialBrokerService::resolveInjectable()` or any method that returns a plaintext secret -- [ ] Implement -- [ ] Test - -### Task 6: PromotionService — remote dispatch via CallService against the target's sourceRef -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-exports-locally-unchanged-and-dispatches-to-the-targets-existing-import-endpoints-req-002` -- **files**: `lib/Service/PromotionService.php` -- **acceptance_criteria**: - - GIVEN a target environment's `sourceRef` Source WHEN a preview or confirm call is dispatched THEN it goes through `CallService::call()` against that Source, unmodified - - GIVEN the dispatch WHEN it completes THEN a `CallLog` is created exactly as for any other Source call -- [ ] Implement -- [ ] Test - -### Task 7: PromotionService — merge target preview response with credentialRefsNeedingRebind -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-diff-preview-merges-the-targets-existing-preview-response-with-a-credential-rebind-classification-req-003` -- **files**: `lib/Service/PromotionService.php` -- **acceptance_criteria**: - - GIVEN a target's `/api/configurations/import/preview` response WHEN merged THEN `creates`/`updates`/`collisions`/`unresolvedReferences`/`credentialsNeedingReentry` are passed through unchanged from the target - - GIVEN flagged `credentialRef` placeholders WHEN merged THEN they appear under a `credentialRefsNeedingRebind` array not present in the target's own response -- [ ] Implement -- [ ] Test - -### Task 8: PromotionController — preview and confirm endpoints with confirmation + action gates -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-requires-explicit-confirmation-and-the-same-action-matrix-authorization-as-exportimport-req-005` -- **files**: `lib/Controller/PromotionController.php`, `appinfo/routes.php`, `lib/actions.seed.json` -- **acceptance_criteria**: - - GIVEN `POST /api/promotions` without `confirmed: true` WHEN called THEN HTTP 400 is returned and nothing is dispatched - - GIVEN a user without `environment.promote` WHEN they call preview or confirm THEN `OCSForbiddenException` is returned before any export or remote call -- [ ] Implement -- [ ] Test - -### Task 9: promotion_audit — write append-only audit object after every promotion attempt -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-every-promotion-attempt-is-recorded-in-an-append-only-promotion-audit-log-req-006` -- **files**: `lib/Service/PromotionService.php`, `lib/Controller/PromotionController.php` -- **acceptance_criteria**: - - GIVEN a successful promotion WHEN it completes THEN a `promotion_audit` object is written with `outcome: "success"`, counts-only `previewSummary`, and the dispatch `CallLog` id - - GIVEN a failed promotion (e.g. target returns 404) WHEN the attempt completes THEN a `promotion_audit` object is written with `outcome: "failed"` and no fabricated `written` summary - - GIVEN an existing `promotion_audit` object WHEN a PUT/DELETE is attempted via the OR object API THEN it is rejected by `appendOnly`/`immutable` enforcement -- [ ] Implement -- [ ] Test - -### Task 10: Formalize the credentialRef pass-through contract on configuration-export-import -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md#requirement-credentialref-authentication-placeholders-pass-through-export-and-import-unresolved-and-untranslated-req-010` -- **files**: `tests/Unit/Service/ConfigurationHandlers/SourceHandlerTest.php` (extend), `tests/Unit/Service/ConfigurationServiceTest.php` (extend) -- **acceptance_criteria**: - - GIVEN a Source with a `credentialRef` placeholder WHEN exported THEN the placeholder is byte-for-byte unchanged in the output (regression test pinning existing, previously-undocumented behaviour) - - GIVEN an OAS document with a non-resolving `credentialRef` WHEN imported THEN the write succeeds and the reference is stored verbatim -- [ ] Implement -- [ ] Test - -### Task 11: Environments & Promotion manifest-v2 UI page -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#non-functional-requirements` -- **files**: `src/views/EnvironmentsPromotion.vue`, manifest-v2 page config (per `openconnector-app-manifest`) -- **acceptance_criteria**: - - GIVEN an operator opens the Environments & Promotion page THEN registered environments are listed with CRUD actions - - GIVEN the environment/target select fields THEN each `NcSelect` carries an explicit `inputLabel` -- [ ] Implement -- [ ] Test - -### Task 12: Promote flow — diff preview + credential rebind prompts + confirm, in its own modal -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-diff-preview-merges-the-targets-existing-preview-response-with-a-credential-rebind-classification-req-003` -- **files**: `src/modals/PromotePreviewModal.vue` -- **acceptance_criteria**: - - GIVEN an operator selects a configuration group and target environment WHEN they open the promote flow THEN the diff preview (creates/updates/collisions/credentialRefsNeedingRebind) renders before any confirm button is enabled - - GIVEN the modal markup WHEN inspected THEN it lives entirely in `src/modals/PromotePreviewModal.vue`, never inlined in a parent component -- [ ] Implement -- [ ] Test - -## Verification -- [ ] All tasks checked off -- [ ] `openspec validate` passes -- [ ] Manual testing against acceptance criteria -- [ ] Code review against spec requirements - -## Tests (company-wide ADR-009) - -- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/`) — `PromotionServiceTest`, `EnvironmentServiceTest`, credentialRef scan/rebind cases -- [ ] Newman/Postman tests for new/changed API endpoints — `/api/environments*`, `/api/promotions*` -- [ ] Browser tests (Playwright MCP) for UI changes — environment CRUD, promote flow diff preview + confirm -- [ ] All tests pass (`composer test`, `newman run`) - -## Documentation (company-wide ADR-010) - -- [ ] Feature documentation updated in `docs/` — Environments & Promotion page, promotion workflow, credential rebinding -- [ ] Screenshot captured and committed to `docs/images/` - -## i18n (company-wide hydra ADR-007) - -- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for the new UI page, promote flow, and error messages +# Tasks: environments-and-promotion (superseded) + +The original 12-task / 35-checkbox list was removed with the 2026-09-02 +retirement (see proposal.md for the disposition; the list survives in +`archive/2026-07-15-environments-and-promotion/tasks.md`, where 25/37 boxes +are checked with per-task evidence, and in git history). The residual +live-instance verification and docs work is listed there with per-box +reasons and belongs to a verification-pack-style follow-up. There is +nothing to implement from this change directly. diff --git a/openspec/changes/environments-and-promotion/test-plan.md b/openspec/changes/environments-and-promotion/test-plan.md deleted file mode 100644 index 0308b2725..000000000 --- a/openspec/changes/environments-and-promotion/test-plan.md +++ /dev/null @@ -1,163 +0,0 @@ -# Test Plan: environments-and-promotion - -## Test Cases - -### TC-1: Creating an environment requires an existing Source reference -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-named-environments-are-openregister-objects-that-wrap-an-existing-source-for-connectivity-req-001` -- **type**: api -- **preconditions**: An admin session; an existing `type: api` Source object -- **steps**: `POST /api/environments` with `slug: "acceptance"`, `role: "target"`, `sourceRef` = the existing Source's uuid -- **expected result**: HTTP 200/201; the `environment` object is created; no new credential material is stored on it -- **test command**: /test-api - -### TC-2: An environment without a resolvable sourceRef cannot be used as a promotion target -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-named-environments-are-openregister-objects-that-wrap-an-existing-source-for-connectivity-req-001` -- **type**: api -- **preconditions**: An `environment` object whose `sourceRef` uuid has since been deleted -- **steps**: `POST /api/promotions/preview` targeting that environment -- **expected result**: Actionable error naming the missing `sourceRef`; no export or remote call attempted -- **test command**: /test-api - -### TC-3: Promotion reuses the unmodified export pipeline -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-exports-locally-unchanged-and-dispatches-to-the-targets-existing-import-endpoints-req-002` -- **type**: api -- **preconditions**: A configuration group `cfg-1` with one Source (apikey set) and one Endpoint -- **steps**: `POST /api/promotions/preview` for `cfg-1` -- **expected result**: The document underlying the preview reflects REQ-001–REQ-005 export/redaction/slug-translation exactly as a manual `/api/configurations/{id}/export` call would produce -- **test command**: /test-api - -### TC-4: Promotion dispatch reuses CallService against the target's environment Source -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-exports-locally-unchanged-and-dispatches-to-the-targets-existing-import-endpoints-req-002` -- **type**: integration -- **preconditions**: Two Integriq instances (A, B) reachable from each other; `environment` object on A pointing at a Source describing B's API -- **steps**: Confirm a promotion from A to B -- **expected result**: A `CallLog` is created on A for the dispatch, identical in shape to any other Source call's CallLog -- **test command**: /test-api - -### TC-5: Preview reflects the target's own creates/updates/collisions classification -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-diff-preview-merges-the-targets-existing-preview-response-with-a-credential-rebind-classification-req-003` -- **type**: api -- **preconditions**: Target environment already has a Source whose slug matches one Source in the export document; a second Source's slug is new -- **steps**: `POST /api/promotions/preview` -- **expected result**: `updates` contains the first Source, `creates` contains the second — sourced from the target's own `/api/configurations/import/preview` response -- **test command**: /test-api - -### TC-6: Preview is computed internally before every confirmed promotion -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-diff-preview-merges-the-targets-existing-preview-response-with-a-credential-rebind-classification-req-003` -- **type**: api -- **preconditions**: Valid configuration id and target environment -- **steps**: `POST /api/promotions` with `confirmed: true` directly, without a separate prior preview call -- **expected result**: The confirm call still computes an equivalent preview internally before dispatching the write (mirrors REQ-008's `import()` behaviour) -- **test command**: /test-api - -### TC-7: A Source's credentialRef is flagged for rebinding -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-credentialref-placeholders-are-re-bound-per-target-environment-never-resolved-to-a-secret-req-004` -- **type**: api -- **preconditions**: Configuration group containing a Source with `configuration.authentication.credentialRef.credentialId` set -- **steps**: `POST /api/promotions/preview` -- **expected result**: `credentialRefsNeedingRebind` contains that Source's slug and field path -- **test command**: /test-api - -### TC-8: Operator-supplied rebinding replaces the reference before the target sees the original (integration, credentialRef re-bind not secret copy) -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-credentialref-placeholders-are-re-bound-per-target-environment-never-resolved-to-a-secret-req-004` -- **type**: api -- **preconditions**: Flagged Source; `credentialBindings` supplying a target-valid `credentialName` -- **steps**: `POST /api/promotions` with `confirmed: true` and the `credentialBindings` entry; capture the outbound document (test double on the dispatch layer) -- **expected result**: The dispatched document's `credentialRef.credentialName` equals the supplied replacement, not the source environment's original `credentialId`; no plaintext secret appears anywhere in the request/response/log -- **test command**: /test-api - -### TC-9: An un-rebound credentialRef is sent verbatim, not resolved or dropped -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-credentialref-placeholders-are-re-bound-per-target-environment-never-resolved-to-a-secret-req-004` -- **type**: api -- **preconditions**: Flagged Source with no `credentialBindings` entry -- **steps**: Confirm the promotion -- **expected result**: The dispatched document's `credentialRef` is byte-for-byte the original; the eventual failure (if any) surfaces only when the target later calls that Source, not during promotion -- **test command**: /test-api - -### TC-10: Promotion without confirmation is rejected -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-requires-explicit-confirmation-and-the-same-action-matrix-authorization-as-exportimport-req-005` -- **type**: api -- **preconditions**: Valid configuration id and target environment -- **steps**: `POST /api/promotions` with `confirmed` omitted or `false` -- **expected result**: HTTP 400; no dispatch; no `promotion_audit` object written -- **test command**: /test-api - -### TC-11: A user without environment.promote cannot promote -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-promotion-requires-explicit-confirmation-and-the-same-action-matrix-authorization-as-exportimport-req-005` -- **type**: security -- **preconditions**: Non-admin user whose groups are unmapped to `environment.promote` -- **steps**: Call preview and confirm endpoints -- **expected result**: `OCSForbiddenException` before any export or remote call -- **test command**: /test-api -- **@e2e exclude**: API-level action-matrix denial has no browser surface — covered by PHPUnit `PromotionControllerTest::testPromoteDeniedForUnmappedNonAdmin` - -### TC-12: A successful promotion is audited -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-every-promotion-attempt-is-recorded-in-an-append-only-promotion-audit-log-req-006` -- **type**: api -- **preconditions**: A confirmable promotion writing two Sources and one Endpoint -- **steps**: Confirm the promotion -- **expected result**: A `promotion_audit` object exists with `outcome: "success"`, correct `fromEnvironmentSlug`/`toEnvironmentSlug`, a counts-only `previewSummary`, and no credential values or full entity payloads -- **test command**: /test-api - -### TC-13: A failed promotion is still audited -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-every-promotion-attempt-is-recorded-in-an-append-only-promotion-audit-log-req-006` -- **type**: api -- **preconditions**: Target environment simulated to return 404 (older Integriq without import routes) -- **steps**: Confirm the promotion -- **expected result**: A `promotion_audit` object exists with `outcome: "failed"` and a message identifying the failure; no fabricated `written` summary -- **test command**: /test-api - -### TC-14: promotion_audit objects cannot be edited or deleted after creation -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-every-promotion-attempt-is-recorded-in-an-append-only-promotion-audit-log-req-006` -- **type**: api -- **preconditions**: An existing `promotion_audit` object -- **steps**: Attempt `PUT`/`DELETE` on it via the OpenRegister object API -- **expected result**: Rejected by `appendOnly`/`immutable` enforcement, matching `call_log`/`job_log` behaviour -- **test command**: /test-api - -### TC-15: A Source's credentialRef is not redacted on export (configuration-export-import delta) -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md#requirement-credentialref-authentication-placeholders-pass-through-export-and-import-unresolved-and-untranslated-req-010` -- **type**: regression -- **preconditions**: A Source with `configuration.authentication.credentialRef.credentialId` set -- **steps**: Export the Source via `SourceHandler::export()` -- **expected result**: The `credentialRef.credentialId` value is unchanged, not `***REDACTED***` -- **test command**: /test-functional (PHPUnit, no browser surface) - -### TC-16: Importing a non-resolving credentialRef does not block the write (configuration-export-import delta) -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/configuration-export-import/spec.md#requirement-credentialref-authentication-placeholders-pass-through-export-and-import-unresolved-and-untranslated-req-010` -- **type**: regression -- **preconditions**: OAS document with a Source whose `credentialRef.credentialId` does not exist on the importing environment -- **steps**: `importConfiguration()` -- **expected result**: The Source object is written with the reference verbatim; no exception at import time -- **test command**: /test-functional (PHPUnit, no browser surface) - -### TC-17: Environments & Promotion page lists environments with accessible selects -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#non-functional-requirements` -- **type**: accessibility -- **preconditions**: At least two seeded environments -- **steps**: Open the Environments & Promotion page; inspect the target-environment `NcSelect` -- **expected result**: Environments render as a list/table; the select carries an explicit `inputLabel` -- **test command**: /test-accessibility - -### TC-18: Promote flow shows diff preview before confirm is enabled -- **spec_ref**: `openspec/changes/environments-and-promotion/specs/environments-and-promotion/spec.md#requirement-diff-preview-merges-the-targets-existing-preview-response-with-a-credential-rebind-classification-req-003` -- **type**: functional -- **preconditions**: A configuration group and a target environment with at least one collision/create -- **steps**: Open the promote flow, select configuration + target -- **expected result**: `PromotePreviewModal` renders creates/updates/collisions and `credentialRefsNeedingRebind`; Confirm is disabled until the preview has loaded -- **test command**: /test-functional - -## Coverage Summary -- REQ-001 (named environments wrap a Source): TC-1, TC-2 — covered -- REQ-002 (promotion reuses export + dispatch): TC-3, TC-4 — covered -- REQ-003 (diff preview reuse + merge): TC-5, TC-6, TC-18 — covered -- REQ-004 (credentialRef re-binding, never secret copy): TC-7, TC-8, TC-9 — covered -- REQ-005 (confirmation + action-matrix authorization): TC-10, TC-11 — covered -- REQ-006 (promotion audit log): TC-12, TC-13, TC-14 — covered -- configuration-export-import REQ-010 (credentialRef pass-through contract): TC-15, TC-16 — covered -- Non-Functional (accessibility, i18n): TC-17 — covered (i18n verified via code review of translation keys, no dedicated TC — all strings added under `l10n/en.json`/`l10n/nl.json` per existing convention) - -## Out of Scope -- Git-backed configuration storage / GitOps (proposal.md Out of Scope) — no test cases. -- Unattended/scheduled promotion — this change is operator-confirmed only; no test cases for automated triggers. -- Multi-hop promotion chains (A→B→C) — not supported; no test cases. diff --git a/openspec/changes/execution-trace-observability/proposal.md b/openspec/changes/execution-trace-observability/proposal.md index c44fd1276..90b07fea9 100644 --- a/openspec/changes/execution-trace-observability/proposal.md +++ b/openspec/changes/execution-trace-observability/proposal.md @@ -1,62 +1,47 @@ -# Proposal: execution-trace-observability - -## Summary -Integriq today records call/job/sync activity as independent per-entity logs (`call_log`, `synchronization_log`, `event_message`) with no shared identifier tying one request's rule → mapping → synchronization → outbound-call path together, and no way to re-run a failed execution. This change mints an execution id at every entry point (endpoint call, job run, event delivery, manual sync), threads it through the existing pipeline, persists an ordered per-execution timeline as a new `execution_trace` OpenRegister object (redacted via the existing `SensitiveFieldRegistry`), adds a Traces UI (manifest v2 list+detail) and a Prometheus counter, and adds dry-run/force replay of a traced failure. - -## Motivation -n8n shipped a per-step execution trace + replay debugging engine (June 2026); this is now a baseline expectation for integration-platform observability and a named competitive gap (Specter insight #1267, Codeberg issue #154). Operators debugging a failed sync today must correlate rows across three separate log schemas by timestamp and source/synchronization id, with no persisted step-by-step view and no one-click re-run. This change closes that gap without introducing distributed tracing (OpenTelemetry export is explicitly out of scope) or a new persistence layer — it is built entirely on existing Integriq/OpenRegister primitives (register.d fragments, FlowToken, SensitiveFieldRegistry, AppHost `tableCount` metrics, dead-letter replay dispatch). - -## Affected Projects -- [ ] Project: `integriq` — mints/propagates an execution id through `EndpointService`/`FlowToken`, `RuleService`, `SynchronizationService`, and `CallService`; adds an `execution_trace` register.d fragment schema; adds `ExecutionTraceService` (Controller→Service→Mapper, ADR-008) for trace assembly, persistence, and replay; adds a Traces manifest v2 page; adds an AppHost `tableCount` Prometheus counter. - -## Scope - -### In Scope -1. An execution id (`traceId`, distinct from the pre-existing, unrelated `correlationId` used by the case-handoff intake engine) minted once per entry point — endpoint call (`EndpointService::handleRequest`), job run, event delivery (`EventService::attemptDelivery`), manual synchronization run — and propagated through rule pipeline → mapping → synchronization → outbound `CallService` calls, so every log/call produced within one logical execution can be joined by `traceId`. -2. A per-execution timeline of ordered steps (type, order, duration, status, input/output snapshot) persisted as one `execution_trace` OpenRegister object per execution. Snapshots reuse `FlowToken`'s existing 8-slot shape where the pipeline already captures request/response/sync-input/sync-output state, and MUST be redacted via the existing `SensitiveFieldRegistry` before persistence — no new redaction logic. -3. Trace persistence as a `register.d` fragment schema (`execution_trace`) with retention modeled on `call_log`'s `x-openregister-archival` pattern. -4. Failed-execution replay: re-run a traced entry point with the same input. Dry-run by default (produces a preview trace, no writes); an explicit `force` flag performs the real write. Reuses the two existing replay dispatch points (`EventService::attemptDelivery`'s action.kind dispatch, `SynchronizationService::replaySynchronizationItem`) rather than inventing a third redispatch mechanism — both are extended with a dry-run parameter that does not exist today. -5. A Traces UI (manifest v2 `"type": "logs"` list page over `execution_trace`, following the `call_log`/`SourceLogs` precedent, plus a detail timeline view; any `NcSelect` filter carries `inputLabel`) and a `traces_total` Prometheus counter added as an AppHost `tableCount` descriptor in `src/manifest.json`, alongside the existing 9 descriptors. -6. Unit tests for trace-id propagation and redaction-in-snapshot; one integration test proving a single endpoint call produces a trace spanning rule → mapping → call. - -### Out of Scope -Distributed tracing across apps (OpenTelemetry export, W3C traceparent propagation to OpenRegister/other Conduction apps) — noted as a follow-up; this change is Integriq-internal correlation only. - -## Approach -Mint the `traceId` at each of the four entry points and carry it as a lightweight `ExecutionTraceContext` value object passed alongside `FlowToken` (not added as a 9th `FlowToken` constructor parameter — `FlowToken` has two existing zero-arg-then-rehydrate call sites that a required id param would break; see design.md Decision 1). Each pipeline stage (rule, mapping step, synchronization item, outbound call) appends one ordered step to an in-memory trace buffer; `ExecutionTraceService` persists the assembled buffer as one `execution_trace` object at the end of the execution (success, short-circuit, or exception). Redaction is applied per-step at snapshot-build time by calling `SensitiveFieldRegistry::redactArray()` directly (not through `CallService`'s local reimplementation — see design.md Decision 3, which flags that asymmetry as pre-existing debt this change does not need to fix but must not copy). Replay re-invokes the existing dead-letter dispatch points with a new dry-run parameter, producing a new `execution_trace` linked to the original via a `replayOf` field rather than mutating the original trace. - -## New Dependencies -None. - -## Impact -- `lib/Service/EndpointService.php` — mint/propagate `traceId` at `handleRequest()`/`doHandleRequest()`, emit rule-step trace entries from `processRules()`. -- `lib/Service/Helper/FlowToken.php` — unchanged (no new constructor param; see design.md). -- `lib/Service/RuleService.php` — emit trace entries for custom rule dispatch. -- `lib/Service/SynchronizationService.php` — propagate `traceId` into `processSynchronizationObject()`/`replaySynchronizationItem()`; add dry-run parameter. -- `lib/Service/CallService.php` — accept/forward `traceId` into `buildAndPersistCallLog()`; no change to existing redaction logic (REQ-006 in `http-call-engine` is unaffected). -- `lib/Service/EventService.php` — propagate `traceId` into `attemptDelivery()`; add dry-run parameter to the replay path. -- `lib/Service/ExecutionTraceService.php` (new) — assembly, persistence, retrieval, replay orchestration. -- `lib/Controller/ExecutionTracesController.php` (new) — list/detail/replay HTTP surface. -- `lib/Settings/register.d/execution-trace-observability.json` (new) — `execution_trace` schema fragment. -- `src/manifest.json` — new `Traces`/`TraceDetail` pages, new `traces_total` observability descriptor. -- `src/views/ExecutionTrace/*.vue` (new) — list + detail Vue components. - -## Cross-Project Dependencies -None — self-contained within `integriq`. No OpenRegister core change is required; the fragment mechanism (ADR-037) and `SensitiveFieldRegistry`/AppHost engine are consumed as-is. - -## Risks - -### Risk 1: Snapshot volume/PII exposure if redaction is skipped on a new code path -**Severity:** High — **Mitigation:** every snapshot-producing step MUST call `SensitiveFieldRegistry::redactArray()` before the step is appended to the trace buffer (never after persistence); the integration test in scope item 6 asserts no plaintext secret survives in a persisted `execution_trace`, mirroring `http-call-engine` REQ-006's existing test pattern. - -### Risk 2: Replay-without-dry-run causing duplicate writes -**Severity:** Medium — **Mitigation:** dry-run is the explicit default at both the controller and service layer (force requires an explicit boolean, never inferred), and dry-run replays never call the underlying write path (`processSynchronizationObject`'s persistence branch, `deliverMessage`) — see design.md Decision 4. - -### Risk 3: Trace-buffer memory growth on pipelines with many steps or large payloads -**Severity:** Low — **Mitigation:** snapshots follow the same size posture as existing `call_log`/`synchronization_log` bodies (no new truncation policy introduced or required beyond what those schemas already accept); flagged as a follow-up if pipelines with very large mapped result sets prove to be a problem in practice. - -## Rollback Strategy -The change is additive: a new register.d fragment (removable by deleting the file — no destructive migration, per ADR-037's version-gated re-import), a new service/controller pair, and threading of an optional `traceId`/`ExecutionTraceContext` parameter through existing methods with safe defaults (`null` disables trace-step emission, preserving current behavior byte-for-byte). Reverting is: remove the fragment file, remove the new controller route registrations and manifest pages, and drop the (default-`null`) trace parameters from the touched method signatures. No existing schema, log shape, or call path is modified. - -## Open Questions -- Should `execution_trace` supersede the currently-unused `sessionId`/`synchronization` correlation fields already present but unpopulated on `call_log` (see design.md), or leave them as separate, still-dead surface for a later cleanup change? Deferred to design.md Decision 5; recommend filing a follow-up issue rather than blocking this change on a `call_log` schema edit. +--- +kind: spec-only +depends_on: [] +--- + +# Proposal: execution-trace-observability (superseded — retired 2026-09-02) + +This directory double-counted a change that had already shipped. Execution +trace observability was implemented and archived on 2026-07-16 +(`archive/2026-07-16-execution-trace-observability`, 27/42 tasks checked +with per-task evidence), yet this live copy was resurrected at 0/41: the +openconnector→integriq rename applied to the prose, the evidence notes +stripped, every box reset. The machinery exists at HEAD: +`lib/Service/ExecutionTraceService.php`, +`lib/Service/Helper/ExecutionTraceContext.php`, +`lib/Controller/ExecutionTracesController.php` with its routes, the +`execution_trace` schema in +`lib/Settings/register.d/execution-trace-observability.json`, trace +propagation through `CallService`/`EndpointService`/`EventService`/ +`JobService`/`SynchronizationService`, and the UI (manifest pages `Traces` +/ `TraceDetail`, `src/views/ExecutionTrace/TraceDetailPage.vue`, +`TraceTimelineWidget.vue`). + +`lib/Settings/register.d/execution-trace-observability.json` references +this directory's `design.md` (Decision 2), so that file stays exactly where +it is as a reference target. The other artifacts (test plan, spec deltas) +are removed; they survive verbatim in the archived twin and in git history. + +## Disposition of the original scope + +| Original scope | Where it went | +| --- | --- | +| `execution_trace` schema, traceId minting + propagation across the rule → mapping → synchronization → call chain, traces controller + routes, timeline UI, Prometheus descriptor, Newman folder authored | **Already shipped and archived**: `archive/2026-07-16-execution-trace-observability` (27/42 boxes checked), code at HEAD | +| Residual verification with substance beyond a live-instance pass: the job-entryPoint replay has **no no-write test mode** (`executeJob()`'s `$forceRun` only bypasses the schedule gate — see the disclosed deviation in `ExecutionTraceService::replayJob()`'s docblock), the suspend→resume trace round trip is unwired-tested, the Traces UI has never been rendered, the trace metric has never been scraped, and the docs page is missing | Open, and honestly unticked in the archived twin with per-box reasons. This is the largest genuine residual of the twelve retirements; it deserves its own verification-pack-style successor (shaped like `approvals-verification-pack`) when the observability track is next picked up — author it then, not here | + +## Sequencing + +Nothing remains to implement from this change directly. The residual +verification above is real but not in flight; author a successor change +when the observability track resumes. + +## Archival + +This directory is retired in place (not moved or renamed): `design.md` is +referenced from the register fragment, and a rename would break that +pointer and detonate every diff-scoped gate. Archive it via the normal flow +only after that comment is repointed. diff --git a/openspec/changes/execution-trace-observability/specs/execution-trace/spec.md b/openspec/changes/execution-trace-observability/specs/execution-trace/spec.md deleted file mode 100644 index ef4eb8bf4..000000000 --- a/openspec/changes/execution-trace-observability/specs/execution-trace/spec.md +++ /dev/null @@ -1,366 +0,0 @@ -# execution-trace Specification - -**Status**: planned -**Scope**: integriq -**OpenSpec changes**: -- `execution-trace-observability` _(in progress)_ - -## Purpose - -Joins one logical Integriq execution — an inbound endpoint call, a cron -job run, a CloudEvent delivery, or a manual synchronization run — under a -single minted id, propagated through the rule pipeline, mapping, -synchronization, and outbound `CallService` dispatch, and persists an -ordered per-step timeline as one `execution_trace` OpenRegister object per -execution. Snapshots are redacted via the existing `SensitiveFieldRegistry` -before persistence. Failed executions can be replayed (dry-run by default, -explicit force for a real write) by reusing the dead-letter and test-mode -machinery that already exists in `dead-letter-replay`, -`synchronization-engine` REQ-011, and `job-management` REQ-JOB-002. See -`design.md` Decisions 1-5 for the propagation, schema, redaction, and -replay mechanics. - -## ADDED Requirements - -### Requirement: Execution id minted at every entry point and propagated through the pipeline (REQ-001) - -The system MUST mint a `traceId` (UUIDv4) at each of the four execution -entry points — `EndpointService::handleRequest()`, a cron-triggered job run -(`JobService::executeJob()`), a CloudEvent delivery attempt -(`EventService::attemptDelivery()`), and a manual synchronization run -(`SynchronizationService::synchronize()`) — before any downstream work -begins, and MUST carry it as an `ExecutionTraceContext` value object passed -alongside the existing `FlowToken` (never as a new `FlowToken` constructor -parameter; see `design.md` Decision 1) through the rule pipeline -(`EndpointService::processRules()`), synchronization item processing -(`SynchronizationService::processSynchronizationObject()`), and outbound -dispatch (`CallService::call()`). Every step recorded during one execution -(REQ-002) MUST carry the same `traceId`. When no `ExecutionTraceContext` is -supplied (e.g. `SourcesController::test()`'s ad-hoc outbound call, or any -other call path not originating from one of the four entry points), no -`traceId` is minted and no trace is recorded — this MUST NOT change -existing behaviour for untraced call paths. - -@e2e exclude backend correlation-id propagation — covered by PHPUnit - -#### Scenario: an endpoint call mints one traceId shared by every downstream step - -- **GIVEN** an endpoint with a `mapping` rule (before) and a `save_object` - rule (before) that dispatches one outbound `CallService` call via a - `synchronization` rule -- **WHEN** a request reaches `EndpointService::handleRequest()` -- **THEN** a single `traceId` is minted before `processRules()` runs -- **AND** the rule step, the mapping step, and the outbound-call step - recorded for this request all carry that same `traceId` - -#### Scenario: an ad-hoc source test call outside any entry point produces no trace - -- **GIVEN** an admin calls `SourcesController::test()` directly -- **WHEN** `CallService::call()` dispatches the test request -- **THEN** no `ExecutionTraceContext` is present -- **AND** no `execution_trace` object is created for that call - -#### Notes - -- `traceId` is a distinct concept from the pre-existing `correlationId` used - by the case-handoff intake engine (`OpenFormulierenIntakeService`, - `DsoIngestService`) — the two are unrelated and MUST NOT be conflated. - -### Requirement: Ordered per-execution step timeline (REQ-002) - -For each execution carrying an `ExecutionTraceContext`, the system MUST -append one ordered `Step` (`order`, `type` ∈ `rule|mapping|synchronization| -call`, `name`, `timing`, `status`, `durationMs`, `startedAt`, redacted -`input`/`output`) to the context's in-memory buffer for: every rule -evaluated by `processRules()` (including skipped rules, per `rule-pipeline` -REQ-RULE-001's skip semantics — skipped rules MUST still produce a step with -`status: 'skipped'`), every mapping application, every synchronization item -processed, and every outbound `CallService::call()` dispatch. Steps MUST -retain the pipeline's actual execution order (`order` matches the sequence -observed, not the rule's configured `order` field alone, since mapping and -call steps interleave between rule steps). - -@e2e exclude backend step assembly — covered by PHPUnit - -#### Scenario: a skipped rule still produces a step - -- **GIVEN** a rule whose JSON-Logic `conditions` evaluate to false -- **WHEN** the pipeline reaches it during a traced execution -- **THEN** a step with `status: 'skipped'` is appended, matching - `rule-pipeline` REQ-RULE-001's existing skip behaviour (no data mutation) - -#### Scenario: step order reflects real execution sequence - -- **GIVEN** a pipeline that runs rule A (order 10), then dispatches an - outbound call from within rule A, then runs rule B (order 20) -- **WHEN** the trace is assembled -- **THEN** the steps appear in the sequence [rule A, call, rule B], not - grouped by type - -### Requirement: Snapshot redaction before any step is buffered (REQ-003) - -Every step's `input`/`output` snapshot MUST be redacted via -`SensitiveFieldRegistry::redactArray()` (never a new or duplicated -redaction implementation) before it is appended to the `ExecutionTraceContext` -buffer. For the `call` step type specifically, the system MUST reuse the -already-redacted `request`/`response` array `CallService::buildResponseData()` -produces for `call_log` persistence (per `http-call-engine` REQ-006) rather -than deriving a second, independent redaction of the same data — see -`design.md` Decision 3. - -@e2e exclude backend redaction — covered by PHPUnit; integration scenario -below is the cross-layer contract test - -#### Scenario: a redacted rule-step snapshot never contains a plaintext secret - -- **GIVEN** an `authentication` rule step whose amended `FlowToken` request - slot carries an `Authorization` header -- **WHEN** the step is appended to the trace buffer -- **THEN** the persisted step's `input.headers.authorization` value is - `***REDACTED***` - -#### Scenario: the call step's snapshot matches the call_log's redacted request/response byte-for-byte - -- **GIVEN** a traced execution whose rule pipeline dispatches one outbound - `CallService::call()` to a source configured with a `client_secret` - form parameter -- **WHEN** the execution completes and both the `call_log` and the - `execution_trace` are persisted -- **THEN** the trace's `call` step `output` equals the `call_log.request`/ - `call_log.response` redacted shape exactly — no divergence, no duplicate - redaction pass - -### Requirement: Trace persistence as one execution_trace object per execution (REQ-004) - -The system MUST persist the assembled `ExecutionTraceContext` as exactly one -`execution_trace` OpenRegister object (register/schema `openconnector` / -`execution_trace`, register.d fragment per `design.md` Decision 2) when the -execution completes — on success, on pipeline short-circuit (e.g. an `error` -rule or approval suspension), or on an uncaught exception (`rule-pipeline` -REQ-RULE-001's HTTP 500 path) — using the minted `traceId` as the object's -own id. Persistence MUST be a single create for every entry point EXCEPT the -approval-suspend/resume continuation (`EndpointService::resumeFromApproval()`), -where the system MUST update the SAME `execution_trace` object (matched by -`traceId`, carried in the rehydrated `ApprovalService::rehydrateFlowToken()` -context) to append the `after`-phase steps rather than create a second, -disconnected trace for the same logical execution. - -@e2e exclude backend persistence orchestration — covered by PHPUnit - -#### Scenario: a successful execution persists exactly one trace - -- **GIVEN** a traced endpoint call that completes successfully -- **WHEN** the response is returned -- **THEN** exactly one `execution_trace` object exists with `status: - 'success'` and every step recorded during the request - -#### Scenario: an approval-suspended execution's resume appends to the same trace - -- **GIVEN** a `before`-phase `approval` rule suspends a traced execution - (`approval-workflow` REQ-001), producing a trace with `status: 'running'` - and the `before`-phase steps -- **WHEN** an approver later approves and `EndpointService::resumeFromApproval()` - runs the remaining `after`-phase rules -- **THEN** the SAME `execution_trace` object (same `traceId`) is updated - with the `after`-phase steps appended and `status` set to its final value -- **AND** no second `execution_trace` object is created for this execution - -#### Scenario: an uncaught rule exception still produces a completed trace - -- **GIVEN** a rule that throws during a traced execution -- **WHEN** the pipeline surfaces the HTTP 500 (`rule-pipeline` REQ-RULE-001) -- **THEN** the `execution_trace` is persisted with `status: 'failed'` and an - `error` object carrying the endpoint name, rule name, rule type, and error - message — the same fields the HTTP 500 body already carries - -### Requirement: Dry-run replay performs no writes (REQ-005) - -`POST /api/execution-traces/{id}/replay` MUST default to dry-run -(`force` absent or `false`) and MUST NOT perform any write with an external -or persisted side-effect for the replayed execution: for a `sync`-entryPoint -trace it MUST invoke `SynchronizationService::replaySynchronizationItem()` -with `isTest: true` (reusing `synchronization-engine` REQ-011's existing -no-write guarantee); for a `job`-entryPoint trace it MUST invoke -`JobService::executeJob()`'s existing test mode (`job-management` -REQ-JOB-002); for an `event`-entryPoint trace of `action.kind: webhook` it -MUST resolve and return the request that would be dispatched WITHOUT -invoking the network call; for an `endpoint`-entryPoint trace it MUST run -`processRules()` with `dryRun: true` (`rule-pipeline` REQ-RULE-010), -suppressing every write-shaped rule's side effect. Every dry-run replay MUST -create a NEW `execution_trace` with `isReplay: true`, `dryRun: true`, and -`replayOf` set to the original trace's id — it MUST NOT mutate the original -trace. - -@e2e exclude backend replay orchestration — covered by PHPUnit - -#### Scenario: a dry-run replay of a failed sync-entryPoint trace makes no writes - -- **GIVEN** a `failed` `execution_trace` with `entryPoint: 'sync'` -- **WHEN** an admin calls replay with no `force` flag -- **THEN** `SynchronizationService::replaySynchronizationItem()` is invoked - with `isTest: true` -- **AND** no `synchronization_contract` or target object is created or - updated -- **AND** a new `execution_trace` is persisted with `isReplay: true, - dryRun: true, replayOf: ''` - -#### Scenario: a dry-run replay of a webhook event-entryPoint trace never dispatches - -- **GIVEN** an `execution_trace` with `entryPoint: 'event'` whose - subscription resolves to `action.kind: 'webhook'` -- **WHEN** an admin calls replay with no `force` flag -- **THEN** the resolved outbound request (URL, method, headers) is returned - in the response -- **AND** no HTTP request is dispatched to the sink - -#### Scenario: a dry-run replay of an endpoint-entryPoint trace skips write rules - -- **GIVEN** an `execution_trace` with `entryPoint: 'endpoint'` whose original - execution ran a `mapping` rule then a `save_object` rule -- **WHEN** an admin calls replay with no `force` flag -- **THEN** the `mapping` rule executes for real and produces a real step -- **AND** the `save_object` rule does NOT persist an object; its step is - recorded with `status: 'skipped_dry_run'` - -### Requirement: Forced replay reuses the original entry point's real dispatch path (REQ-006) - -`POST /api/execution-traces/{id}/replay` with `force: true` MUST perform a -real write using the SAME dispatch mechanism the original execution would -have used, never a bespoke re-implementation: `sync`-entryPoint traces -dispatch via `SynchronizationService::replaySynchronizationItem(isTest: -false)`; `job`-entryPoint traces dispatch via `JobService::executeJob()` -with test mode off; `event`-entryPoint traces dispatch via the existing -`EventService::attemptDelivery()` / `dead-letter-replay` REQ-DLR-003 path -unchanged; `endpoint`-entryPoint traces dispatch via `processRules()` with -`dryRun: false` (ordinary execution). A forced replay MUST NEVER read -outbound-call credentials from the stored (redacted) trace snapshot — -Source-level authentication MUST be re-resolved live by `CallService` from -the Source object exactly as in the original execution, matching the -existing `sync_item_dead_letter.payload` pattern where the stored payload is -business data, never a credential. Every forced replay MUST create a new -`execution_trace` with `isReplay: true`, `dryRun: false`, `replayOf` set to -the original trace's id. - -@e2e exclude backend replay orchestration — covered by PHPUnit - -#### Scenario: a forced replay of a failed sync-entryPoint trace writes for real - -- **GIVEN** a `failed` `execution_trace` with `entryPoint: 'sync'` whose - original mapping bug has since been corrected -- **WHEN** an admin calls replay with `force: true` -- **THEN** `SynchronizationService::replaySynchronizationItem()` is invoked - with `isTest: false` -- **AND** the corresponding `synchronization_contract` is created/updated as - if the item had succeeded on first processing -- **AND** a new `execution_trace` is persisted with `isReplay: true, - dryRun: false, replayOf: ''` - -#### Scenario: forced replay resolves live credentials, never the redacted snapshot - -- **GIVEN** an `execution_trace` whose `call` step snapshot carries - `***REDACTED***` in place of the original Source's `Authorization` header -- **WHEN** an admin calls replay with `force: true` -- **THEN** the replayed outbound call carries the Source's current live - credential (resolved fresh by `CallService`), never the literal string - `***REDACTED***` - -### Requirement: Traces UI — typed list and detail timeline (REQ-007) - -The app's manifest MUST expose a `Traces` page (`"type": "logs"`, following -the `SourceLogs`/`EndpointLogs`/`CloudEventLogs` precedent, config -`{register: 'integriq', schema: 'execution_trace'}`) listing traces -with filters for `entryPoint`, `status`, and time range, and a `TraceDetail` -view rendering the ordered step timeline (type, duration, status per step, -with redacted input/output expandable per step) plus a "Replay" action -(dry-run by default, an explicit confirmation step required before a forced -replay). Every `NcSelect` filter control MUST carry an `inputLabel` prop -(never a bare `