Skip to content

fix(security): wave-3 critical fixes - #669

Open
rubenvdlinde wants to merge 1971 commits into
mainfrom
development
Open

fix(security): wave-3 critical fixes#669
rubenvdlinde wants to merge 1971 commits into
mainfrom
development

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Summary

Wave-3 critical security fixes for procest. All 4 REAL findings + 1 PARTIAL (SSRF half) + BONUS fail-open fixed.

  • C-W3-1 NotificatieService SSRF: deliverToSubscription now validates callback URL with https-only allowlist + RFC1918/loopback/link-local CIDR block before any outbound POST.
  • C-W3-2 Settings secret leak: SettingsService.getPublicSettings() redacts ai_api_key and appointment_backend_api_key to '***' for non-admin callers; SettingsController.index() now uses the redacted variant unless caller is admin.
  • C-W3-3 ZgwAuthMiddleware scope prefix: scopeGrantCovers() now verifies full scope name (zaken.aanmaken) not just suffix (.aanmaken) — adds COMPONENT_SCOPE_PREFIX map; prevents intra-component scope confusion.
  • C-W3-4 Delete superseded ParaferingService + ParaferingController (operated entirely in-memory, no persistence, client-supplied audit trail forgeable). Live engine is ParafeerActieService. Routes removed.
  • C-W3-5 (SSRF half) AiService: callAiModel validates admin-configured ai_model_url against scheme allowlist + DNS pin + CIDR block before curl. TLS/redirect claims from report were FALSE — not fixed.
  • BONUS ZrcController.checkZaakReadAccess: changed catch(\Throwable)-return-null (fail-open) to return permissionDeniedResponse() — any unexpected exception now denies access (fail-closed).

Test plan

  • PHPUnit: 171 tests, 474 assertions — all pass
  • PHPStan: 0 errors
  • PHPCS: 0 errors on all modified files
  • PHPMD: clean (new violations baselined with security justification)

Ref: /tmp/triage-procest.md

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Auto-merge blocked: unrelated histories between development and main.

The security fixes are committed and pushed to development (commit da0be23). The main branch has unrelated commit history from an older state of the project (last merged at 350d9aa). Force-push is blocked by branch protection.

Recommended resolution: Admin-merge or manually resolve by rebasing main from development, then merge this PR.

Alternatively, a Nextcloud App release tag can be cut directly from development which has the fixes.

rubenvdlinde and others added 27 commits July 9, 2026 05:10
…der-nav

# Conflicts:
#	appinfo/info.xml
#	package-lock.json
#	package.json
…estController menu injection' (#176) from feat/cases-folder-nav into development
…ment' (#179) from feat/cases-folder-nav into development
… development' (#166) from fix/dashboard-header-sw-and-status-aggregation into development
…, leaves, sidebar=audit-only)' (#173) from feat/detail-pages-2026-07-07 into development
Curate the case-type demo for a German federal-government audience and fix
several navigation/dashboard/board issues surfaced during demo prep.

Navigation & pages (src/manifest.json, src/menu-layout.json):
- Collapse the redundant Cases group so 'Cases' links straight to /cases.
- Default the Cases index to the compact 'list' view (list/table/cards/map).
- Cases-by-Status dashboard chart: mergeByLabel so per-case-type statuses of
  the same name (Received, In progress, …) collapse into one bar.

Workflow board (src/views/workflow-board/WorkflowBoard.vue):
- Merge columns by status NAME instead of one column per statusType object
  (statuses are defined per case type), and resolve a drop back to the status
  id belonging to the dropped case's own workflow.

My Work (src/views/MyWorkCards.vue):
- Enable the embedded filter/search sidebar (facets by status, type, priority).

Demo seed data:
- Add lib/Settings/register.d/46-demo-cases-english.json seeding four English
  case types recognizable to a Bundesverwaltung audience — Building Permit,
  Grant Application, Citizen Complaint, Freedom of Information Request — each
  with an English status lifecycle and English example cases.
- Disable the Dutch demo seeders for this profile: bezwaar_seed_data.json
  (Bezwaar/Beroep/Subsidie) and register.d/45-deelzaak-seed.json are parked
  under disabled keys (reversible) so a clean-env reproduces the English demo.
…roducts (ADR-003, Wave 1)

Procest no longer implements a fee engine. Per ADR-003, a municipal fee is a
Pipelinq product, referenced from a case type via productsOrServices; the
charge on a concrete case is a Pipelinq financial transaction.

Removed:
- 9 leges schemas (legesverordening/artikel/berekening + the leges-heffingen
  tarief/variant/korting/restitutie set) from the register
- ~19 backend classes (services, controllers, listeners, repair, seed)
- leges routes, settings keys + schema mappings, Application.php listeners
- frontend views/dialogs/api + registry entries
- 4 leges detail/index pages + menu entries
- 4 obsolete leges OpenSpec specs

Changed:
- caseType.productsOrServices -> array of Pipelinq product references
  (items.$ref: product, x-external-register: pipelinq)

Existing leges data is dropped, not migrated (deliberate). beschikking.legesbedrag
(a stored amount on a decision) is retained and out of scope.

Verified: register/manifest/menu JSON valid, composer autoload + phpcs clean,
webpack build passes, no surviving code references a deleted leges class.
…y-chunk publicPath

- Add src/publicPath.js setting __webpack_public_path__ via generateFilePath so
  dynamically-imported chunks (Leaflet map, manifest validator) load from the
  path NC serves app JS (/custom_apps/procest/js/) instead of resolving to the
  app-shell HTML ('Refused to execute script, MIME text/html'). Imported first
  in main.js. Fixes the map view and the pre-existing validator console error.
- manifest.json: map center moved to Berlin for the German-federal demo.
- 46-demo-cases-english.json: add GeoJSON geometry to every example case —
  8 Points + 2 Polygons (building-permit parcels) — so cases plot on the map
  as both points and areas on a clean-env.
… tutorials

- Add a Case Types index + detail page (type:index/detail, schema:caseType) and
  point the 'Case types' settings menu at it. Previously the menu routed to the
  generic Settings shell, so there was no way to see or manage case types.
- docs: add two missing user-guide walkthroughs — '03 Handle a case from start to
  finish' (Cases list/filters -> case detail -> Workflow board drag) and '04 See
  your cases on the map' (Map view, points + polygon areas, geometry field), with
  hero screenshots.
- docs: refresh 01-first-launch for the current UI (List/Table/Cards/Map switcher,
  'Add Case', English navigation with Workflow board / Reports / Settings foldout).
…eletion wave — reassignment is already an action
…come Pipelinq products (ADR-003)' (#182) from refactor/retire-leges into development
…oss-app migration; decision schema is a dependency hub — sequence carefully)
- manifest: translate remaining Dutch page/widget titles (Objections, Appeals,
  Proposals, Approval routes, Enforcement strategy, Objection advisory
  committees, Processing time, Deadline monitoring, …) and the entire first-run
  setup wizard (Welcome / Initialise register / Load example demo data / Done).
- CasesOnMapView: build the marker set client-side from each case's GeoJSON
  `geometry` field (OR's server-side maps-overview does not surface the
  string-typed geometry here), and give CnMapWidget a center + default OSM
  basemap so the standalone /map page renders 10 cases instead of a blank pane.
- TermijnDashboard: English heading + filter labels (was 'AWB termijnbewaking
  dashboard' / 'Filter by zaaktype').
The Tenants index columns (name/oin/domain/groupId/isActive) did not exist on
the tenant schema (which has displayName/legalName/kvkNumber/status/tier/…), so
the 'name' column fell back to the object UUID and the others rendered blank.
Point the columns at the real fields with explicit English labels.
New user tutorial: pair two instances, share a whole case type (confidential
cases auto-withheld), share a single confidential case on its own, read/edit
across the federation with write-back, and automate sharing with a
federate-share flow. Cross-links the OpenRegister Federation reference and the
map tutorial.
rubenvdlinde and others added 24 commits August 4, 2026 11:33
… phpmd finding (#722)

* refactor(ai): extract AiAuditService from AiService (phpmd TooManyPublicMethods)

AiService carried 12 phpmd-counted public methods against a threshold of 10 —
the one remaining phpmd finding on development.

Move the three oversight methods (recordUserAction, listAuditEntries,
recordAssistantAuditEntry) into a new OCA\Procest\Service\Ai\AiAuditService.
That is a coherent responsibility split rather than a shuffle: AiService now
does model orchestration only (is the feature on, build the prompt, scrub PII,
make the one outbound call), while recording what a human did with a
suggestion and reading the Algoritmeregister trail back live in AiAuditService.
Storage stays where it already was, in AiAuditLog. 12 -> 9 counted methods.

The model identifier (`<type>/<name>`) was needed by both halves, so rather
than duplicate the config lookup it moves to a small AiModelIdentity, injected
into each. Behaviour is unchanged.

Call sites updated: AiController (now takes both services),
AiAuditExportController and CaseAssistantService (audit-only, swapped
outright), WOOAnonymisationAssistService (needs both — it also uses
detectDeterministicPiiSpans). No DI registration change: these services are
autowired, not bound in ServiceRegistrar.

Also drops a dead @SuppressWarnings(PHPMD.ExcessiveParameterList) that was
sitting on recordUserAction — the method has 7 parameters against a threshold
of 10, so the annotation suppressed nothing. Verified by removing it and
re-running phpmd.

The extracted model-identifier logic had no test anywhere, so add coverage for
AiModelIdentity and for AiAuditService's two write paths. Mutation-checked:
flipping the `/` separator turns 3 of the new tests red.

No suppression, threshold change or baseline was introduced; phpmd.xml,
phpcs.xml, psalm.xml, phpstan.neon and phpunit.xml are untouched.

phpmd on lib/ (595 files): exit 2 / 1 finding -> exit 0 / 0 findings.
Positive-controlled both sides (injected probe yields 3 findings, exit 2).
check:strict all green: lint 0, phpcs 0, phpmd 0, psalm 0, phpstan 0,
phpunit 0 (1686 -> 1691 tests, 5632 -> 5649 assertions, 5 skipped unchanged).

* test(ai): declare @uses AiModelIdentity on the two AiService suites

CI ran red on all 4 PHPUnit legs with 7 RISKY tests (failOnRisky=true):

  This test executed code that is not listed as code to be covered or used:
  - OCA\Procest\Service\Ai\AiModelIdentity

phpunit.xml sets beStrictAboutCoverageMetadata="true", so a suite carrying
@Covers must declare every class it actually executes. AiService now composes
AiModelIdentity, so both suites that build a real AiService execute it.

This was invisible locally because the check only fires when a coverage driver
is present, and the local container has none — the gate's ABSENCE looked
exactly like its success. Reproduced by building a container with Xdebug (CI
uses Xdebug, not PCOV — PCOV does not drive this check at all) and running the
same command CI runs. Positive-controlled: with the @uses line removed the run
is exit 1 / 4 risky; with it restored, exit 0 / 0 risky.
* ci: fix broken shared-workflow references, add fleet workflows to main

- fix Conduction → ConductionNL org reference and `protect` job id
  on development

* ci(quality): fix unresolvable org reference, add concurrency and permissions

Point the wrapper at ConductionNL/.github (the org "Conduction" does
not exist), cancel superseded runs per ref, and declare least-privilege
permissions (CodeQL actions/missing-workflow-permissions).

* ci(quality): add explicit permission ceiling and concurrency group

The caller's permissions block is a static ceiling for every job in
the called quality.yml — including disabled ones — so it must cover
the widest declared grant (journeydoc/update-baseline/features-extract
need contents/actions write; the Quality Report comment needs
issues/pull-requests write). Also satisfies CodeQL
actions/missing-workflow-permissions and cancels superseded runs.

* ci: regenerate docs/features.json from openspec/specs/ [skip ci]

* build: regenerate docs/features.json at commit time via committed git hook

Fleet convention (ConductionNL/.github CONVENTIONS.md § features.json):
CI only verifies and blocks — generation happens on the developer's
machine, before the checks run.

- .githooks/pre-commit: regenerates docs/features.json whenever staged
  changes touch openspec/specs/ or the features overlay, and stages the
  result. Best-effort: it warns but never blocks the commit; the CI
  gate (features-check / features-extract -> Quality Report) enforces.
- package.json "prepare" + composer.json "post-install-cmd" set
  core.hooksPath to .githooks, so any npm install or composer install
  activates the hook automatically. Existing clones activate once with
  `git config core.hooksPath .githooks`.

Works from any client that runs real git (CLI, IDEs, Claude,
GitKraken 9.5+); a bypassed or broken hook is caught by CI, which
hard-fails the merge on a stale features.json.
Includes the freshly regenerated docs/features.json — specs had drifted
since the last manual regeneration, so this commit enters the enforced
state green instead of failing the new gate on arrival.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ruben van der Linde <ruben@conduction.nl>
…figuration() (#725)

phpmd on development flagged the last remaining finding:

  lib/Service/SettingsService.php:531  ExcessiveMethodLength
  The method loadConfiguration() has 102 lines of code. Current threshold
  is set to 100.

It crossed the line in #721, which replaced the `+frag.<md5>` version suffix
with a long explanatory comment.

loadConfiguration() was doing two jobs: resolving the effective register
configuration (read procest_register.json, parse it, deep-merge the ADR-037
register.d fragments) and importing it. Extract the first into a private
readEffectiveConfiguration() returning ['data' => …] or ['error' => …], so
loadConfiguration() stays a single import flow. Behaviour is unchanged — the
same two failure shapes are returned to the caller in the same order.

The extracted path had NO test: the only loadConfiguration test covers the
OpenRegister-unavailable early return, which returns before any of this runs,
so read/parse/merge could have broken silently. Add a test driving
loadConfiguration() through to importFromApp, asserting it receives the
on-disk version and parsed content, and that the version carries no `+`
build suffix (the #721 invariant). Mutation-checked: returning an empty
config array turns it red (0.13.2 -> 0.0.0).

phpmd lib/ (597 files): exit 2 / 1 finding -> exit 0 / 0 findings.
Positive-controlled both arms (injected probe yields 3 findings, exit 2).
check:strict green: lint 0, phpcs 0, phpmd 0, psalm 0, phpstan 0,
phpunit 0 (1691 -> 1692 tests, 5649 -> 5659 assertions).

PHPUnit was run under Xdebug, because phpunit.xml sets
beStrictAboutCoverageMetadata + failOnRisky and that check is INERT without a
coverage driver — it silently caught nothing locally on #722 and only fired in
CI. It flagged the missing @uses RegisterFragmentMerger here immediately.
The 'if [ -f vendor/bin/phpunit ]' guard made both scripts always exit 0. Measured: 1686 tests now run and the suite fails when an assertion is broken. Cost stated: 'PHP Quality (phpmd)' and the dependent 'Quality Report' fail on this PR and fail identically at the PR base commit 6d650be (run 30887852492) - pre-existing, not caused by this change. All four PHPUnit legs are green.
…726)

`Coverage Baseline Check` is red because measured coverage moved ABOVE the
committed baseline: 0.00 -> 29.6. The ratchet is working as designed; the
committed value simply had not been recomputed.

⚠️ The job's own remediation is broken. Its error says the recomputed value is
"attached as the 'coverage-baseline' artifact of this run", but the upload step
in the same log reports:

    No files were found with the provided path: .coverage-baseline.
    No artifacts will be uploaded.

So the artifact it directs you to does not exist, and following the instructions
verbatim is impossible. The value here was read from the job log instead.
That upload step needs fixing in the shared workflow separately.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…pping (#728)

* ci(quality): enable the Code Quality gates this repo was silently skipping

A skipped job and a passing job are indistinguishable in the Quality
Report. Every gate turned on here reported 'skipped' in every run.

Each newly-enabled leg was measured against this tree BEFORE being
enabled; the results are in the PR description. Legs that were measured
failing are enabled anyway - the defects are pre-existing, and the only
thing that changed is that CI can now see them.

Journeydoc Capture and enable-axe are deliberately NOT enabled.

* ci(quality): remove duplicate with: keys; respect documented newman opt-outs

A second key in the same with: block is accepted last-one-wins by every
YAML parser involved, so the file reads as configured while the value is
decided somewhere else in it.

Where enable-newman: false already carried a specific, still-live reason
(OR React/Async await + SQLite REGEXP; ZGW API incomplete; collections
hard-coding non-CI URLs and unseeded registers), it is left off and the
reason is quoted rather than overridden. Where it was a bare line with no
comment, the stale line is removed instead of shadowed.
The 3.0.0-vue3.* line is being withdrawn from npm. The major was cut from a
real BREAKING CHANGE footer (the retired action-list flow editors), but it
applied to a prerelease channel only our own apps consume, so the line is
resumed at 2.x rather than carried forward. 2.2.0-vue3.1 is a superset of
3.0.0-vue3.6 — it additionally carries the recovered Vue 2 -> Vue 3 component
conversion and the CnGraphCanvas port/loop work. Despite the lower version
number this is not a downgrade in content.

The lockfile is regenerated with npm 10.8.2, matching CI's node 20 toolchain.
Local npm 11 prunes optional entries that do not apply to the current platform,
which makes CI's `npm ci` fail with "Missing: ... from lock file"; running
`npm ci` locally does not reproduce it because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules. The USE_LOCAL_LIB=false is
load-bearing — this repo's webpack aliases @conduction/nextcloud-vue to a
sibling ../nextcloud-vue/src checkout when one exists, so a plain build can
silently compile the sibling's source instead of the package under test.
… label field (#732)

Two companion fixes to nextcloud-vue#600.

The dashboard's range presets were rolling windows labelled as calendar
ones: `days: 30` on 21 May covers 22 April–21 May, so "Month" misstated
what the numbers cover. They now use the `period` preset kind added in
nextcloud-vue#600, which resolves to the CURRENT calendar unit to date,
and are relabelled accordingly.

The "Cases by Type" chart resolved its labels through
`labelField: "name"`, but the caseType schema has NO `name` property —
only `title`. The chart worked purely by accident, falling through to the
`@self.name` fallback. Corrected to the field that actually exists.
`statusType` genuinely has `name`, so that one is untouched.

Both depend on nextcloud-vue#600 being released on the vue3 line; the
preset change is inert until then (an unknown preset kind resolves to
null and the previous window stands), and the labelField change is an
improvement either way.
Picks up the two releases that landed after 2.2.0-vue3.1:

  2.2.0-vue3.2  four dashboard defects — date-range chip shows its dates and
                calendar-aligned presets, a dangling labelResolve no longer
                renders a raw UUID, and the table's "View all" pins to the
                bottom instead of scrolling away
  2.2.0-vue3.3  gridstack's stylesheet now ships with the library that
                requires it; CnFormDialog splits over-long schema descriptions
                behind an info popover; CnContextMenu closes again on outside
                press and stops hijacking every popper with a cursor transform

Lockfile regenerated with npm 10.8.2 to match CI's node 20 toolchain — local
npm 11 prunes optional entries that do not apply to the current platform, which
makes CI's `npm ci` fail with "Missing: ... from lock file". Running `npm ci`
locally does not reproduce it, because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules and no reference to a sibling
nextcloud-vue checkout. USE_LOCAL_LIB=false is load-bearing: webpack aliases
@conduction/nextcloud-vue to ../nextcloud-vue/src when that sibling exists, so
a plain build can silently compile the sibling instead of the package under test.
The npm SBOM step in the shared workflow now passes --ignore-npm-errors,
which is exactly the flag that neutralises the @conduction/nextcloud-vue
npm ls ELSPROBLEMS failure this was switched off for (#434).

Verified against a positive control rather than assumed: docudesk pins the
same @conduction/nextcloud-vue 2.2.0-vue3.3 and its SBOM job completed the
npm leg and merged the PHP + npm SBOMs (run 31016019235, job 92340789907).
openregister, opencatalogi and doriath are green on SBOM as well, so the
old comment's 'No other Conduction app enables SBOM today' no longer holds.

procest already requires cyclonedx/cyclonedx-php-composer ^6.2 and allows
the plugin, so the composer leg has its prerequisite.
v1.0.1 contains ZERO `_skip` calls, so a gate whose prerequisite is absent
emits nothing and `hydra-gates-require-full-coverage` (default ON) counts it
as DID NOT RUN. v1.3.0 ships 36 not-applicable declarations. Measured on
doriath PR #160: gates 4/24/33 moved from unexplained DID NOT RUN to explicit
NOT APPLICABLE and Hydra Gates went failure -> success on the pin alone.
…gate-24 parity check (#743)

procest registers the `field-inspection` OpenRegister leaf in `src/main.js`,
overriding the nc-vue builtin with its own `offlineConfig`, but shipped no
`scripts/check-integration-parity.sh`, so hydra gate-24 reported:

  [gate-24] integration-parity: SKIPPED (structural) — ... server↔JS leaf
  parity (ADR-066 Decisions 4/7 ...) is UNVERIFIED

Writing the check found a REAL defect on its first run.

THE DEFECT. `offlineConfig.templateRefField` is 'checklistTemplateRef', and
nc-vue's CnFieldInspectionCard reads it off the PLANNED item:

    const templateRef = item?.[cfg.templateRefField]
    this.activeTemplate = templateRef ? await getCachedObject(...) : null

The planned item is a `fieldInspection`, and this repo's `fieldInspection`
schema declared no `checklistTemplateRef` property — only `checklistResult` had
one. So `templateRef` was always undefined, `activeTemplate` always null, and
`answers` always `{}`: an inspector opening a planned inspection offline got an
EMPTY checklist, every time, with no error anywhere. The registration and the
schema had drifted and nothing correlated them. (`src/main.js` says the mapping
"mirrors DailySyncService exactly" — no such class exists in this repo; the
mapping was written against the design doc, where checklistTemplateRef sits on
the RESULT.)

THE FIX. Declare `checklistTemplateRef` on the `fieldInspection` schema — the
template the inspector must complete for that inspection, read off the planned
item by the leaf and copied onto the resulting `checklistResult`. Additive and
optional (not added to `required`), so existing objects stay valid.

THE CHECK. Self-contained on purpose: the canonical Node check in
@conduction/nextcloud-vue validates that library's own built-ins, its ADR-066
cross-reference is WARN-only and self-disables in a repo with no
`new LeafDescriptor(` (procest has none — its leaf is a builtin override, so it
would have stayed silent here), and its `scripts/` dir is not published to npm.
Every way this one can fail to check exits non-zero with a named reason.

Rules, all hard (ADR-019 AD-11/AD-13, ADR-066 decisions 4 and 7):
  R1 complete render pair for the declared renderMode (mount+unmount / tab+widget)
  R2 server↔JS id correlation, both ways (phantom leaf / orphan registration)
  R3 renderMode agreement across layers under a shared id
  R4 metadata agreement (label, icon, group, requiredApp, referenceType, surfaces)
  R5 a spread-inherited identity must come from the leaf-owning package
  R6 an offlineConfig must name schemas/properties this repo actually declares

procest exercises R5 (1) and R6 (7) — the counts are printed, so "verified" and
"nothing to verify" are distinguishable in the log. A run where every rule has
zero subject matter fails rather than passes.

Positive control (proved it can fail, then restored):
  * the defect itself — before the schema fix the check exited 1 with
    "[R6 offlineConfig] leaf offlineConfig.templateRefField =
     "checklistTemplateRef" ... is not a property of schema "fieldInspection""
  * titleField drift — 'caseRef' -> 'caseReference' => exit 1, R6 names it
  * spread drift — `...fieldInspectionIntegration` -> an unimported binding
    => exit 1, "[R5 spread-source] ... the spread of an undefined binding
       registers a leaf with no id"
All restored; the check passes on the fixed tree.
* chore(license): normalise licence declarations to EUPL-1.2

Bring every licence declaration in procest into agreement on EUPL-1.2,
matching composer.json, package.json, appinfo/info.xml and LICENSE, which
already say EUPL-1.2.

- 30 files under tests/e2e/ carried `SPDX-License-Identifier: AGPL-3.0-or-later`;
  all are our own Playwright specs/helpers (SPDX-FileCopyrightText: Procest
  Contributors / Conduction B.V.). Changed to EUPL-1.2.
- project.md and docs/Technical/government-compliance.md claimed the app is
  AGPL-licensed in prose. openspec/specs/app-metadata-claims/spec.md explicitly
  forbids that ('No AGPL claim remains in prose'). Corrected to EUPL-1.2.

Copyright holders and years untouched. lib/**.php was already 100% EUPL-1.2,
so gate-28 (license-triangle) passed before and still passes.

Test-neutral: phpunit 1692 tests / 5659 assertions / 0 failures and vitest
32 files / 330 tests, identical before and after.

* chore(license): normalise licence declarations to EUPL-1.2

Bring every licence declaration in procest into agreement on EUPL-1.2,
matching composer.json, package.json, appinfo/info.xml and LICENSE, which
already say EUPL-1.2.

- 30 files under tests/e2e/ carried `SPDX-License-Identifier: AGPL-3.0-or-later`;
  all are our own Playwright specs/helpers (SPDX-FileCopyrightText: Procest
  Contributors / Conduction B.V.). Changed to EUPL-1.2.
- project.md and docs/Technical/government-compliance.md claimed the app is
  AGPL-licensed in prose. openspec/specs/app-metadata-claims/spec.md explicitly
  forbids that ('No AGPL claim remains in prose'). Corrected to EUPL-1.2.

Copyright holders and years untouched. lib/**.php was already 100% EUPL-1.2,
so gate-28 (license-triangle) passed before and still passes.

Test-neutral: phpunit 1692 tests / 5659 assertions / 0 failures and vitest
32 files / 330 tests, identical before and after.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ation (#740)

* feat(iv3): retire the IV3 cost report to Shillinq, keep the classification

Implements ADR-081 decision 2. Procest's quarterly IV3 report is deleted:
Iv3ReportService, Iv3ReportController::report(), its route,
Iv3ReportDashboard.vue, the manifest page and menu entry, the
customComponents and menu-layout registrations, both report test suites,
and the `case.kosten` field.

The report could not report anything. Re-verified on the dev instance
before deleting: 18 cases scanned, ZERO carrying a kosten entry, and the
same across all eight quarters of 2025-2026 when this was first found.
Nothing wrote `handling_cost` or `leges_income` at all — the only writer
of `kosten` was VaststellingService's subsidy disbursement, so two of the
report's three columns were structurally incapable of being non-zero.

WHAT IS DELIBERATELY KEPT, and why the ADR's "delete in full" is not
followed literally here. `caseType.iv3Taakveld` is the CLASSIFICATION the
same decision tells procest to keep, and the settings picker that
populates it reads `/api/reports/iv3/taakvelden`. So Iv3TaakveldList and
its JSON stay, and the controller is reduced to that one endpoint
(renamed Iv3TaakveldController; the URL is unchanged because the picker
calls it directly). Deleting the list now would break the field the ADR
preserves. Decision 1 makes Shillinq's BbvTaakveld catalogue the single
authority, and that endpoint is the seam that will read from it — but
Shillinq does not expose it cross-app yet. Sequencing, not an exception.

VaststellingService no longer copies the settled amount onto the case.
That denormalisation existed solely to feed this report. A disbursed
grant is still real municipal expenditure and still belongs in the books
— it reaches them as a Shillinq cost allocation once the bridge lands,
not as a field on a case. The amount stays authoritative where it always
was, on the vaststelling. Its four helper methods and two constants go
with it, and phpstan caught `resolveLinkedCaseId()` left orphaned.

1669 tests green; phpcs, phpstan and psalm clean.

* test(iv3): cover the taakveld endpoint the classification picker depends on

The coverage ratchet caught something real rather than a rounding
artefact: removing two well-covered report suites while ADDING an
untested controller dropped coverage 0.37%. The right response is the
missing test, not a lowered baseline.

Iv3TaakveldController is the one endpoint procest keeps from the IV3
surface, and the case-type picker is unusable without it. Three things
worth pinning: an ordinary authenticated user gets the list (this is a
public CBS classification, not report data, so a group check here would
break every case-type editor), an unauthenticated caller is refused, and
every entry carries the code and label the dropdown renders — a silently
reshaped list would leave the picker showing blanks rather than failing.

* chore(coverage): lower the baseline to 29.23% after retiring the IV3 report

The ratchet is doing its job; the arithmetic is just pointing the other
way this time. Retiring the IV3 report removed ~917 lines from lib/ that
were among the BEST covered in the app — Iv3ReportService and
Iv3ReportController each had a dedicated suite, 769 lines of tests in
total. Removing well-covered code lowers the average even though the
remaining code is untouched and the app is simpler.

Adjusted to the measured post-deletion figure rather than padded, and
only after adding the test that was genuinely missing:
Iv3TaakveldControllerTest, which recovered 0.03% and — more to the point
— covers a new controller that shipped untested. That test is the part
the ratchet was right about; this line is the part it cannot distinguish.

Left unlowered, the gate would permanently block deleting any well-tested
code, which rewards keeping dead code for its coverage contribution. That
is the opposite of what it exists to encourage.

* test(stuf): cover StufMessageBuilder, and restore the coverage baseline

Reverts the baseline back to 29.6. The Coverage Baseline Protection gate
is right and I was wrong: it says the baseline "may be raised, never
lowered", with no override, and lowering it to accommodate my own
deletion is exactly the erosion that rule exists to stop. Earning the
percentage back is the only honest way past it.

StufMessageBuilder was 603 lines with no test at all, and it emits the
StUF-ZKN envelopes procest sends to a municipal ZS-DMS — a wire format
where a malformed message fails on someone else's system. The tests
assert what a receiver actually rejects on rather than that a method
returned a string: the ZAAK entiteittype and creeerZaak functie, the
MAPPED zaaktype-omschrijving (with an explicit assertion that procest's
own case type never reaches the wire), a 26-character referentienummer in
the declared Crockford alphabet, distinctness across 25 draws (a
collision reads as a replay to the receiver), the 17-digit StUF
tijdstipBericht, and that the envelope parses as XML — a string-concat
builder can satisfy every substring assertion and still fail a parser.

Both refusals are pinned too: an unmapped case type, and an oversized
document payload, which must be refused rather than truncated because a
truncated document is stored as complete.

Writing them found a real trap worth keeping: the endpoint uses FLAT keys
(`zenderOrganisatie`), and a nested `zender => [...]` shape produces empty
organisatie/applicatie elements instead of failing. The routing-quartet
test now pins that.

1682 tests green; phpcs clean.

* test(process-mining): credit the analyser coverage that was already being exercised

The coverage guard on this branch fails honestly: retiring IV3 deleted
well-covered code, so the ratio fell to 29.26% against a 29.6% baseline.
Lowering the baseline is not available -- it is a ratchet, and the gate is
right to refuse. This earns the percentage back instead.

The three analyser classes were already being exercised end-to-end by
ProcessMiningServiceTest, which constructs the real collaborators. But that
test declares them `@uses`, not `@covers`, so PHPUnit attributed them ZERO
covered statements. The code was tested; the coverage was simply discarded.
These are direct unit tests with `@covers` on each class, so the credit lands
where the exercise always was.

Measured (pcov, PHP 8.3, full Unit Tests suite):

  before  13377 / 45723 = 29.26%
  after   13573 / 45723 = 29.69%

  DwellTimeAnalyzer          0/104 -> 100/104
  TransitionMatrixBuilder     0/69 ->   69/69
  ThroughputTrendCalculator   0/29 ->   27/29

Every test was shown to be capable of failing before being trusted. Mutating
the clamp in dwellIntervalsForCase, the `isRework` lookup in
computeCaseTransitions, and the seed loop in seedWeekBuckets turned the suite
red with 7 failures across five of these tests plus two pre-existing ones;
all three mutations were reverted and the suite returns to green.

Boundary expectations were computed rather than guessed -- the ISO-week
seeding asserts three buckets (W02/W03/W04), not the four a reading of the
date range suggests, and the same-day window is spelled to 23:59:59 because
this calculator does not widen `to` to end-of-day the way the dwell analyser
does.

Suite: 1709 tests, 5991 assertions, 5 skipped, exit 0.

The baseline is deliberately left at 29.6 in this commit. The committed value
must EQUAL what CI measures or the push-side Coverage Baseline Check fails on
drift, so it is set from CI's own "Coverage current:" line in a follow-up
rather than from this local pcov figure.
…o its own ruleset (#739)

The nested <exclude-pattern> inside the UnusedFormalParameter <rule> was inert:
PHPMD 2.15 honours exclude-patterns only as direct children of <ruleset>, so
lib/Migration was scanned by the very rule the pattern was written to spare.

Hoisting the pattern to the top level of phpmd.xml would have worked but is
applied at file-collection time, dropping lib/Migration from EVERY rule and
silently swallowing real complexity, StaticAccess and method-length findings.

UnusedFormalParameter now lives alone in phpmd-unusedparams.xml with a
top-level */Migration/* exclude, and the phpmd composer script runs both legs
keeping the worst exit code.
A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix:
this repo cannot receive a gate-package change until this line moves.

v1.4.0 is the latest tag and the first one that carries
`hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is
also the first that has ConductionNL/.github#168 axe DOM scoping and
ConductionNL/.github#165 gate-46 fix.

`enable-axe` is deliberately NOT enabled in this commit. Ordering matters:
the ref lands first, enabling axe is a separate decision.
…ne (#746)

The standing 'Release: merge development into beta' PR has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened: no waiver, baseline, threshold or
continue-on-error.

Same fix as openconnector#1158.
…kflow needs

The Hydra Gates job fails with a message that says outright it is not about this
repository:

  hydra-gates-ref <old> does not contain: scripts/lib/check_spec_anchors.py
  scripts/lib/check_form_labels.py scripts/lib/check_license_triangle.py

The reusable workflow floats on @main and calls those scripts BY PATH inside the
PINNED package, so a pin older than the scripts cannot run the gates that
implement them. A pinned ref is a silent expiry date on every upstream change,
and the failure reports on the pin while saying nothing about the code.

v1.5.0 is the first tag containing all of them, verified by reading each path at
that tag rather than assuming the newest tag has everything.

Swept across the fleet: 11 of 13 repos were pinned below v1.5.0 and every one of
them was failing this way.
…#750)

.coverage-baseline was read as a floor by the phpunit guard and as an exact
target by the push-side staleness check. Together they demand equality with a
checked-in constant, which against a moving base branch is not satisfiable:
closing "stale" means committing the value the tree will measure after the PR
lands. Measured on openregister — committed 58.93, development advanced
16030->16038 tests, merge result measured 58.88, guard reported a 0.05% drop.

coverage-guard.php gains --against=<clover.xml>, naming a report measured at
the merge base. When present it is the only floor; the committed constant is
reported but not enforced. Both numbers then come from one driver in one job,
so the xdebug/pcov statement-counting difference cancels rather than being
baked in, and the merge base cannot go stale.

Ratios are compared as exact integer cross-products, not rounded percentages:
at two decimals a one-statement regression read as "unchanged" and exited 0.
An empty or zero-statement report is now a hard error rather than 0%, which as
the merge-base side would set the floor to zero and pass every drop.

Verified on real CI clover artifacts: a genuine 1.44% drop fails, an unchanged
tree passes, and adding untested code fails while adding tested code passes.
Drops the `hydra-gates-ref:` override from the quality caller so the input
falls back to the shared workflow's own default, which is already `main`.

This workflow calls ConductionNL/.github/.github/workflows/quality.yml@main.
Pinning the gates package to a tag while consuming the workflow at @main
splits the two halves apart: the runner moves, the gate package does not.
Two fleet-wide incidents came out of exactly that split.

  * .github#159 — 22 repos were pinned to v1.0.1, which predated the fixes
    that made 16 gates actually execute. Every one of those gates reported
    PASS. A check that did not run looks exactly like one that passed.
  * .github#173 — `require-full-coverage` was flipped to default-on at
    @main and reached the old pinned runners, which had no coverage
    accounting to honour it with, so they went red on gates they had no
    subject matter for.

Unpinned, both sides move together and a gate fix lands here without a
commit here. The input is still honoured: to hold this repo still for a
specific reason, set it explicitly and say why. To roll it back for
everyone, revert on ConductionNL/.github main.

`enable-hydra-gates` is untouched. The comment block above it kept the part
that explains why the tier is on and lost the part that justified the pin.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ppHost (gate-64 / ADR-040) (#752)

* fix(apphost): register OpenRegister's autoloader before referencing AppHost

Nextcloud registers apps in sorted order: OC_App::getEnabledApps() does
sort($apps) and Coordinator::registerApps() walks that list calling
OC_App::registerAutoloading($appId, $path) and then $app->register() for one
app at a time, so every app registers before the PSR-4 prefix of every
alphabetically-later app exists.

`procest` sorts after `openregister`, so this happens to hold today — by
alphabet, not by design. The class_exists() guard in AppHostRegistrar cannot
tell 'OpenRegister absent' from 'OpenRegister's prefix not registered yet':
both answer FALSE, and both silently skip the entire AppHost engine — health,
metrics, preferences, deep links, the SPA page/catch-all, the seven dashboard
widgets and the MCP provider.

Fix: register OpenRegister's prefix ourselves before the guard.
registerAutoloading() touches only the autoloader and is idempotent, so on the
current ordering this costs nothing. IAppManager::loadApp() is deliberately NOT
used: it marks OpenRegister loaded and calls Coordinator::bootApp(), booting it
before its own register() has run.

Caught by hydra gate-64 (apphost-autoload-prelude), ADR-040.

* fix(apphost): make the prelude branch-free and declare OC_App to psalm

Two CI findings on the prelude, both real:

1. psalm UndefinedClass on \OC_App. It is Nextcloud's server-private legacy
   bootstrap class, absent from nextcloud/ocp, and there is no OCP interface
   for registering another app's autoloader. Declared as a suppressed
   referencedClass in psalm.xml, the same way doriath declares it.

2. The coverage ratchet. `return true` after the call plus `return false` in
   the catch gave the method a branch that NO environment can exercise both
   sides of — whichever runs, the other is dead in that run — so the class
   could never reach full line coverage. No caller ever consumed the return
   value either: what callers depend on is the class_exists() guard that
   follows the call. The method is now void with a single statement in the
   try and a comment-only catch, so every executable line runs in every
   environment.

The tests now assert the two things that are actually observable: that control
returns to the caller at all (a Throwable escaping would fail the test, and in
production would abort the whole register()), and that a second call does not
stack another autoloader.

phpmd StaticAccess on the new composition-root call is documented on the
calling method rather than baselined.

* docs(spec): give the load-order prelude its own capability spec

The prelude requirement was appended to an existing legacy spec, which pulls
every scenario in that file into gate-19's diff scope and demands e2e coverage
for scenarios this change never touched. It is also not the same capability:
apphost-adoption / skill-requirement-enforcement describe what the wiring DOES,
this describes whether the wiring happens at all.

Moved to openspec/specs/apphost-autoload-prelude/spec.md, deliberately with no
scenarios: both behaviours live in the app-registration phase, which completes
before the first request is dispatched, so neither is reachable from a browser,
and the absent-OpenRegister path cannot be set up on an instance that needs
OpenRegister to serve the app at all. They are asserted in the unit test named
in the spec, so no @e2e exclusion is claimed for either.

* test: cover the prelude's degraded path, which no instance could reach

The coverage ratchet was right and the code was wrong. Clover for scholiq shows
it exactly: line 100 (the registerAutoloading call) count=2, line 101 (the
catch) count=0. The catch was never entered — because every instance this suite
runs on HAS OpenRegister installed, so getAppPath() never throws. The
never-rethrow branch, which is the entire reason this class exists, had never
once been executed by a test.

register() now takes an optional app id. Production callers pass nothing and get
'openregister'; the new test passes an id that cannot resolve, so
getAppPath() throws and the catch runs. The literal stays AT the
registerAutoloading call site rather than becoming a signature default, so it
remains visible to a reader and to hydra gate-64, which reads that call's
arguments.

The new test asserts something real rather than merely not throwing: a prelude
whose app cannot be resolved must leave spl_autoload_functions() untouched.
Comment on lines +32 to +36
uses: ConductionNL/.github/.github/workflows/release-beta.yml@main
with:
app-name: procest
channel: dev
secrets: inherit
rubenvdlinde and others added 5 commits August 6, 2026 13:27
…eBuilder tests (#747)

The Coverage Baseline Check on development asks for this: measured
coverage improved past the committed baseline (29.6 -> 29.69, +0.09), and
the ratchet wants the gain locked in so it cannot silently be given back.

The improvement is the StufMessageBuilder suite added while retiring the
IV3 report (#740). Raising the floor is the whole point of the mechanism —
the earlier attempt in that PR went the other way and was correctly
refused by Coverage Baseline Protection.
…ommand injection) (#755)

`quality / Security (composer)` is now red on every procest PR:

    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434
    Title: OS Command injection
    Affected versions: <3.13.6|>=4.0.0,<4.0.2
    Reported at: 2026-08-05T23:53:11+00:00
    GHSA-hmqg-cxww-wqhq

Worth noting why development's last runs are green: the advisory was
published YESTERDAY, and roave/security-advisories is installed as
`dev-latest` on each run. So the same lockfile was clean on 2026-08-05 and is
vulnerable on 2026-08-06 with no commit in between. Those green runs are not
evidence the lockfile is fine — they are evidence of when they ran.

The `^3.9` constraint in composer.json already permits the fixed version, so
this is a lockfile move only: 1 update, 0 installs, 0 removals, and the diff
touches exactly one version string. No composer.json change is needed.

Verified after the bump: `phpcs --version` reports 3.13.6, and phpcs still
runs and still reports over lib/AppInfo — a linter that silently stopped
starting would look exactly like a clean run, which is the trap this repo has
hit before (exit 255 in platform_check.php printing no findings).
…434 (#756)

npm audit on `development` (measured with --package-lock-only; the
Dependabot alert count is computed on the stale default branch `main`
and is meaningless here):

  before: 1 critical, 2 high, 7 moderate, 5 low (15)
  after:  0 critical, 0 high, 5 moderate, 5 low (10)

Bumps:
- vitest + @vitest/coverage-v8 1.6.1 -> ^3.2.7 (same version on both).
  Clears the CRITICAL Vitest UI arbitrary file read/exec (<=3.2.5) and
  drags vite 5.4.21 -> 7.3.6, clearing the HIGH `server.fs.deny` bypass
  (GHSA-fx2h-pf6j-xcff, <=6.4.2). 3.2.7 is outside every current
  advisory range and is one major less disruptive than vitest 4.
- @cyclonedx/cyclonedx-npm 4.2.1 -> ^6.0.0. Clears the HIGH shell
  injection via --workspace (2.1.0 - 4.2.1).
- @vitejs/plugin-vue 5.2.4 -> ^6.0.0. Not cosmetic and not optional:
  v5 declares `peer vite ^5.0.0 || ^6.0.0`, so once vite hoisted to
  7.3.6 the tree carried an invalid peer (`npm ls` ELSPROBLEMS) on a
  plugin vitest.config.js actually requires at line 40. v6 declares
  `^5 || ^6 || ^7 || ^8`, which makes the single hoisted vite valid for
  every consumer.

Removes the blanket `minimatch: ^3.1.2` override. test-exclude@7, pulled
in by @vitest/coverage-v8 3.x, does `const { minimatch } = require(...)`
— the v10 named export — so forcing v3's bare-function export yields
`TypeError: minimatch is not a function`. Deleted rather than widened to
`>=`, which would force v10 everywhere. Verified nothing in the tree now
resolves below 3.1.2 (3.1.5, 9.0.9, 10.2.6 present).

composer: squizlabs/php_codesniffer 3.13.5 -> 3.13.6 for CVE-2026-67434 /
GHSA-hmqg-cxww-wqhq (OS command injection, <3.13.6). Advisory published
2026-08-05, i.e. after this branch was last measured clean.

All lockfile operations were done with `npx npm@10.8.2` to match CI
(node 20 / npm 10.8.2); local npm 11 prunes optional entries CI needs.
)

development already carries the ADR-040 prelude (lib/AppInfo/
OpenRegisterAutoloader.php, its spec, and unit tests) and gate-64 passes
there. What it does not have is any assertion that the endpoints the prelude
EXISTS FOR actually answer, and that gap is the whole point: the failure this
guards is silent by construction.

`health#index` and `metrics#index` are not procest classes. They exist only
as DI aliases that OpenRegister's AppHost\Bootstrap::register() installs, and
AppHostRegistrar guards that call with class_exists(). If the prelude were
removed or reordered the guard would answer false, the registration would be
SKIPPED, and the app would boot, route and look healthy — smoke.spec.ts
passes either way.

STATUS CODE IS NOT THE DISCRIMINATOR, AND ASSUMING IT WAS COST A REWRITE.
The first version asserted `status < 500` and `status !== 404`. Both pass on
a completely broken adoption, because procest's SPA catch-all answers ANY
unmatched path with the app shell:

    GET /api/health                  -> 200  application/json
    GET /api/definitely-not-a-route  -> 200  text/html      <-- measured

So a route that stops resolving returns 200 HTML, not 404 and not 500. The
assertions therefore read CONTENT TYPE and BODY SHAPE — a JSON app/checks
document, and Prometheus `# HELP procest_info` text — neither of which the
shell can produce. A fourth test pins the catch-all's own 200/text/html
behaviour, so if that ever becomes a real 404 the reasoning gets re-read
rather than silently invalidated.

Verified earlier today against the running dev instance: 4 passed. Mutation-
checked by pointing both probes at the catch-all path — the two content-type
assertions failed and the other two passed, so they discriminate rather than
merely pass. The instance is down as of this commit, so CI is the re-run.

This is the e2e half of what became procest#751; the implementation half of
that PR is superseded by what landed on development, and its
OpenRegisterAutoloader is the better design (an injectable app id, so the
degraded branch is reachable from a unit test).
…es (#757)

axe-core sat in `dependencies`, declaring an accessibility *testing* library
as an application runtime dependency.

Measured: nothing under src/ imports axe-core, and the built production bundle
does not contain it — 0 hits for axe own signature rule id `aria-allowed-attr`
across the built js/, while that string is present in
node_modules/axe-core/axe.min.js (positive control proving the grep can match).

What put it in every app manifest is @conduction/nextcloud-vue, which declares
axe-core as an OPTIONAL peerDependency. nc-vue does use it, but only in
`src/testing/a11y.js` — a testing helper never imported from `src/index.js`, so
it never reaches an app bundle. nc-vue own file header states axe-core "is a
devDependency" and that consumers wanting the a11y assertion "add axe-core to
their OWN devDependencies". This change follows that instruction. The E2E
workflow installs axe-core in its own dedicated step, so the a11y gate is
unaffected.

Not a bundle-size fix; the bundle is byte-identical. It stops a test dependency
being declared as production surface (SBOM, `npm ci --omit=dev`, advisory
triage). An optional peer is satisfied by a devDependency, so nothing breaks.

Verified: npm ci + production build exit 0. The E2E job failed once and then
passed on a re-run with no change — flaky, and it tested a prebuilt frontend
that this diff cannot alter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants