From c0ed65e8a61d00dca41e0963d4ac1f038c5ed77c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 7 Oct 2026 21:56:20 +0200 Subject: [PATCH 1/2] docs(parity): re-rate the connection rows and inv-compat-server against the code --- openspec/parity/capabilities.json | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 495d53f..c1fef7d 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -450,13 +450,15 @@ "evidence": "lib/Service/Source/AppStoreSource.php:661, lib/Service/Source/AppStoreSource.php:631, lib/Service/Cache/ArtifactCache.php:204, lib/Service/InstallerService.php:334, src/components/ServerCompatBadge.vue:19, src/App.vue:2379, lib/Command/ListVersions.php:114", "reachedOn": "Apps tab, version picker (Runs on this server / Not for this server version); occ versioniq:versions (Runs on this server column); GET /api/app/{appId}/versions (serverCompatible)", "owner": "ConductionNL/versioniq", - "change": "inventory-upgrade-readiness" + "change": "inventory-upgrade-readiness", + "readOn": "2026-10-07", + "spec": "openspec/specs/version-management" }, "provider": "versioniq", "providerHow": "read-from-code", "feature": "version-management", "featureConfidence": "high", - "note": "Each App Store version carries serverCompatible from the release's platformVersionSpec against the running server, and a forge release held in the artifact cache carries it from the nextcloud range in its own info.xml. The web version picker shows it as a badge and occ versioniq:versions as a column. A forge release that was never downloaded still reads unknown, because forges publish no range in release metadata, so this stays partial. Specified 2026-09-27 in openspec/changes/inventory-upgrade-readiness (openspec pass).", + "note": "Re-rated 2026-10-07 (parity scan), state and rating unchanged. The built half is specified in the version-management main spec (Fetch Available Versions: serverCompatible on each App Store version and on cached forge releases, shown in the web picker): App Store versions carry serverCompatible from platformVersionSpec, cached forge releases from their own info.xml, shown by ServerCompatBadge in the picker and as a column in occ versioniq:versions. The missing half stays specified in inventory-upgrade-readiness: a forge release that was never downloaded reads unknown, because the forge range lookup (ForgeRangeStore) is not built. Partial is the right rating until that lands.", "evidence": { "nextcloud": "apps/appstore/lib/Controller/ApiController.php:133-135 computes missingDependencies and isCompatible; shown in apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue:137 and filterable via 'Show incompatible' (apps/appstore/src/components/AppToolbar.vue:105). Applies to the single latest release only.", "renovate": "lib/config/options/index.ts:499 constraintsFiltering filters releases by language constraints (engines, PHP), and lib/modules/datasource/nextcloud/readme.md has you point registryUrls at a per-platform apps.json so only releases for that server version are offered; not shown to the user as a compatibility verdict.", @@ -4946,17 +4948,18 @@ "osv-scanner": "unknown", "easy-updates-manager": "partial", "built": { - "state": "building", - "evidence": "src/components/IntegrationsPanel.vue:30, src/utils/connectionRegistry.ts:25, lib/Settings/connections.json:4, lib/Settings/Admin.php:53", - "owner": "ConductionNL/integriq", + "state": "built", + "evidence": "lib/Settings/connections.json:4 declares the appstore, github and advisories connections; lib/Service/Connection/ConnectionReportService.php reports what a real request met, called from lib/Service/Source/AppStoreSource.php:259, lib/Service/Advisory/NextcloudAdvisoryFeed.php:151 and lib/Controller/ApiController.php:1100 (token save) and :1197 (token removal); lib/Settings/Admin.php:53 provides integriq-installed; src/components/IntegrationsPanel.vue:152 shows each row status and :156 when integriq last checked it (checkedAt); src/App.vue:242 adds the Integrations tab only when integriq is installed.", + "owner": "ConductionNL/versioniq", "reachedOn": "Integrations tab (only when integriq is enabled)", - "change": "integriq/connection-registry" + "change": "adopt-connection-registry", + "readOn": "2026-10-07" }, "provider": "integriq", "providerHow": "read-from-code", "feature": "connection-registry", "featureConfidence": "high", - "note": "Reachability and last result for the App Store, GitHub and the advisory feed are shown from integriq's connection registry; without integriq there is no status view, and neither Codeberg nor the self-hosted Forgejo or Gitea forge has a row. Building: ConductionNL/integriq openspec/changes/connection-registry, an existing open change, covers it (integriq lane, OpenSpec pass 2026-09-27).", + "note": "Re-rated 2026-10-07 (parity scan): built through versioniq change adopt-connection-registry (all code tasks done; only the live e2e run against an instance with integriq, task 5.1, is open) on top of integriq change connection-registry, archived on integriq development as 2026-09-29-connection-registry. The Integrations tab shows reachability and last check time for the App Store, GitHub and the advisory feed. Stays partial: without integriq there is no status view, and a self-hosted Forgejo or Gitea forge has no row (Codeberg was left out on purpose, it is retired).", "evidence": { "nextcloud": "Admins get a notification after 3, 7, 14 and 30 days of failed update checks (apps/updatenotification/lib/BackgroundJob/UpdateAvailableNotifications.php:30,88-94), the last check time is shown (Settings/Admin.php:44), and a setup check tests internet connectivity (apps/settings/lib/SetupChecks/InternetConnectivity.php:41). Store fetch failures are only logged (Fetcher.php:100-103).", "renovate": "Lookup failures surface as dependency lookup warnings and a \"Repository Problems\" section on the dashboard (lib/workers/repository/dependency-dashboard.ts:333); no per-source reachability or last-read view.", @@ -6034,17 +6037,18 @@ "osv-scanner": "unknown", "easy-updates-manager": "partial", "built": { - "state": "building", - "evidence": "src/App.vue:2525, src/components/IntegrationsPanel.vue:30, lib/Settings/connections.json:4, lib/Settings/Admin.php:53", - "owner": "ConductionNL/integriq", + "state": "built", + "evidence": "lib/Settings/connections.json:4 declares the appstore, github and advisories connections; lib/Service/Connection/ConnectionReportService.php reports what a real request met, called from lib/Service/Source/AppStoreSource.php:259, lib/Service/Advisory/NextcloudAdvisoryFeed.php:151 and lib/Controller/ApiController.php:1100 (token save) and :1197 (token removal); lib/Settings/Admin.php:53 provides integriq-installed; src/App.vue:2735 mounts src/components/IntegrationsPanel.vue, which lists integriq app_connection rows for app=versioniq and links Add integration to integriq (:111).", + "owner": "ConductionNL/versioniq", "reachedOn": "Integrations tab (only when integriq is enabled)", - "change": "integriq/connection-registry" + "change": "adopt-connection-registry", + "readOn": "2026-10-07" }, "provider": "integriq", "providerHow": "read-from-code", "feature": "connection-registry", "featureConfidence": "high", - "note": "An Integrations tab lists the App Store, GitHub and advisory feed rows from integriq's registry; it is absent without integriq and has no Codeberg or self-hosted Forgejo or Gitea row. Building: ConductionNL/integriq openspec/changes/connection-registry, an existing open change, covers it (integriq lane, OpenSpec pass 2026-09-27).", + "note": "Re-rated 2026-10-07 (parity scan): built through versioniq change adopt-connection-registry (code tasks done, live e2e task 5.1 open) on integriq connection-registry, archived on integriq development as 2026-09-29-connection-registry. One Integrations tab lists the App Store, GitHub and advisory feed connections. Stays partial: the tab is absent without integriq, and a self-hosted Forgejo or Gitea forge has no row.", "evidence": { "nextcloud": "The Security and setup warnings overview runs checks such as internet connectivity, email test and cron (apps/settings/lib/SetupChecks/InternetConnectivity.php, EmailTestSuccessful.php, CronInfo.php); it does not cover the app store or update server health.", "renovate": "Mend cloud shows each repository's Renovate status (failed, timeout, resource-limit) in the Developer Portal (docs/usage/mend-hosted/job-scheduling.md:19-40); nothing comparable in the self-hosted CLI.", From cadcab04cc60cfc153812e536404ce9908b11c83 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 7 Oct 2026 21:59:37 +0200 Subject: [PATCH 2/2] docs(openspec): specs for the built capabilities that had none, and link their rows --- openspec/parity/capabilities.json | 54 +++++++----- openspec/specs/admin-api/spec.md | 71 ++++++++++++++++ openspec/specs/migration-safety/spec.md | 57 +++++++++++++ .../security-advisory-correlation/spec.md | 82 ++++++++++++++++++- openspec/specs/version-management/spec.md | 57 +++++++++++++ 5 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 openspec/specs/admin-api/spec.md diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index c1fef7d..a1ce89f 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -480,10 +480,11 @@ "easy-updates-manager": "yes", "built": { "state": "built", - "evidence": "src/App.vue:1140, src/App.vue:2125", + "evidence": "src/App.vue:1196 (filteredApps: label or id, case-insensitive, after the core and updates filters), src/App.vue:2240 (Hide core apps), src/App.vue:2256 (Search apps input)", "reachedOn": "Apps tab", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/version-management", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -914,10 +915,11 @@ "easy-updates-manager": "no", "built": { "state": "built", - "evidence": "src/App.vue:1180, src/App.vue:2317", + "evidence": "src/App.vue:1268 (filteredVersions), src/App.vue:2505 (Filter versions input), src/App.vue:2579 (No versions match your filter.)", "reachedOn": "Apps tab (app detail, Versions)", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/version-management", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -1910,10 +1912,11 @@ "easy-updates-manager": "partial", "built": { "state": "built", - "evidence": "lib/Service/SelectedReleaseInstallerService.php:133, lib/Service/SelectedReleaseInstallerService.php:604", + "evidence": "lib/Service/SelectedReleaseInstallerService.php:133 (verifyCertificate: root chain, CRL, revoked serial, CN equals app id), lib/Service/SelectedReleaseInstallerService.php:604 (SHA-512 archive signature)", "reachedOn": "Apps tab (any App Store install)", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/migration-safety", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -2096,10 +2099,11 @@ "easy-updates-manager": "yes", "built": { "state": "built", - "evidence": "lib/Service/InstallerService.php:524, lib/Service/InstallerService.php:719", + "evidence": "lib/Service/InstallerService.php:609 (maintenance on for a real install when it was off), lib/Service/InstallerService.php:807 (switched off in finally); tests/unit/Service/InstallerDryRunSideEffectsTest.php:98 (dry run never writes it)", "reachedOn": "Apps tab (any install); occ versioniq:install; background job AutoUpdateJob", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/migration-safety", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -3832,7 +3836,8 @@ "evidence": "lib/BackgroundJob/AdvisoryRefreshJob.php:114, lib/Service/Advisory/AdvisoryDigestNotifier.php:116, lib/Notification/Notifier.php:172", "reachedOn": "background job AdvisoryRefreshJob, Nextcloud notification centre (weekly, Apps tab toggle)", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/security-advisory-correlation", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -3860,10 +3865,11 @@ "easy-updates-manager": "no", "built": { "state": "built", - "evidence": "src/App.vue:2066, lib/Controller/ApiController.php:861, lib/BackgroundJob/AdvisoryRefreshJob.php:65, src/utils/advisoryFreshness.ts:29, src/App.vue:724", + "evidence": "lib/Service/Advisory/AdvisorySettingsStore.php:36 (6 h default, 1 to 24), lib/BackgroundJob/AdvisoryRefreshJob.php:65 (setInterval), lib/Controller/ApiController.php:916 and :947 (GET and PUT /api/advisory/settings, 400 outside the bounds), src/App.vue:2185 (interval field), src/utils/advisoryFreshness.ts:29", "reachedOn": "Apps tab, security advisory checks (check every N hours)", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/security-advisory-correlation", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -3925,7 +3931,8 @@ "evidence": "lib/Service/Advisory/BranchAwareRange.php:72, lib/Service/Advisory/AdvisoryService.php:274", "reachedOn": "background job AdvisoryRefreshJob, Apps tab badges", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/security-advisory-correlation", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -4049,7 +4056,8 @@ "evidence": "lib/Service/Advisory/NextcloudAdvisoryFeed.php:263, lib/Service/Settings/InstanceSettings.php:105, src/components/InstanceSettingsPanel.vue:178", "reachedOn": "Settings tab (Advisory feed address); occ config:app:set versioniq advisory.feed_base", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/audit-trail", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -5356,7 +5364,8 @@ "evidence": "lib/Controller/ApiController.php:1207", "reachedOn": "OCS API GET /api/audit (no export control in the UI)", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/audit-trail", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -5793,7 +5802,8 @@ "evidence": "lib/Controller/ApiController.php:53, openapi.json:1", "reachedOn": "OCS API under /ocs/v2.php/apps/versioniq/api", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/admin-api", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", @@ -5813,7 +5823,7 @@ "area": "admin", "name": "Publish an OpenAPI description of the API.", "rowSource": "own-code", - "versioniq": "yes", + "versioniq": "partial", "nextcloud": "yes", "renovate": "unknown", "dependabot": "yes", @@ -5821,16 +5831,17 @@ "easy-updates-manager": "no", "built": { "state": "built", - "evidence": "openapi.json:1", + "evidence": "openapi.json:1 (OpenAPI 3.0.3, 24 paths, generated by composer run openapi), .github/workflows/openapi.yml:113 (regenerates and fails on a diff); missing: GET /api/updates (lib/Controller/ApiController.php, from inventory-pending-updates #493) is not in openapi.json on development", "reachedOn": "openapi.json in the app package", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/admin-api", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", "feature": "version-management", "featureConfidence": "high", - "note": "A generated OpenAPI 3.0.3 description ships with the app.", + "note": "A generated OpenAPI 3.0.3 description ships with the app and CI checks it is fresh. Re-rated 2026-10-07 from yes to partial: the committed openapi.json lacks the GET /api/updates route added in #493, so the description is one route behind and the openapi check is red until it is regenerated.", "evidence": { "nextcloud": "apps/appstore/openapi.json and apps/updatenotification/openapi.json are generated from the controllers; published at https://docs.nextcloud.com/server/latest/developer_manual/_static/openapi.html.", "renovate": "not checked: no OpenAPI description found in the repo; Mend portal APIs were not read.", @@ -5883,10 +5894,11 @@ "easy-updates-manager": "partial", "built": { "state": "built", - "evidence": "src/App.vue:1923, src/App.vue:337, src/App.vue:1963, src/components/HistoryPanel.vue:145", + "evidence": "src/App.vue:2024 (skip link to #versioniq-main), src/App.vue:2070 (tablist with aria-selected, aria-controls and roving tabindex), src/App.vue:337 (Left, Right, Home, End keys), src/App.vue:2509 (labelled filter input), src/components/HistoryPanel.vue:151 (scope=col headers); tests/e2e/shell.spec.ts:38 drives the arrow keys", "reachedOn": "whole admin page", "owner": "ConductionNL/versioniq", - "spec": "none" + "spec": "openspec/specs/version-management", + "readOn": "2026-10-07" }, "provider": "versioniq", "providerHow": "read-from-code", diff --git a/openspec/specs/admin-api/spec.md b/openspec/specs/admin-api/spec.md new file mode 100644 index 0000000..76c0f6b --- /dev/null +++ b/openspec/specs/admin-api/spec.md @@ -0,0 +1,71 @@ +--- +status: implemented +--- + +# Admin API Specification + +**Status**: implemented +**Scope**: versioniq +**Standards**: Nextcloud OCS API, OpenAPI 3.0.3, nextcloud/openapi-extractor + +## Purpose + +Everything an administrator does on the Versioniq page can also be done by a script or another system. The page itself is a client of the same OCS API, so there is no action the API lacks. The API is described in a generated `openapi.json` that ships with the app. + +## Requirements + +### Requirement: Every page action is an OCS API route + +Every Versioniq action the admin page performs MUST go through an OCS route under +`/ocs/v2.php/apps/versioniq/api/`, declared with `#[ApiRoute]` on an `OCSController` +(`lib/Controller/ApiController.php`, `lib/Controller/SettingsController.php`, +`lib/Controller/ForgeController.php`). This covers listing apps and versions, installing a +version, source binding, pins, update policies, auto-update settings, advisories and their +settings, pending updates, the audit log, the artifact cache, discovery, tokens, trusted +sources and the instance settings. Each route MUST refuse a caller who is not a Nextcloud +administrator with HTTP 403, and each route that changes state MUST require password +confirmation. Responses MUST use the OCS envelope with typed data. Listing and installing are +also available as `occ` commands (cli-commands). The Integrations tab is the one exception: it reads integriq's connection rows through integriq, not through this API (adopt-connection-registry). + +#### Scenario: A script lists the installed apps + +@e2e tests/e2e/pending-updates.spec.ts + +- **GIVEN** an administrator's app password +- **WHEN** a script calls `GET /ocs/v2.php/apps/versioniq/api/apps` with `OCS-APIRequest: true` +- **THEN** the response MUST carry the installed apps in the OCS `data` field + +#### Scenario: A non-admin is refused + +@e2e tests/e2e/pending-updates.spec.ts + +- **GIVEN** an authenticated user who is not an administrator +- **WHEN** they call any Versioniq API route +- **THEN** the system MUST respond with HTTP 403 +- **AND** no data MUST be returned + +### Requirement: The API is described in a generated OpenAPI document + +The app MUST ship `openapi.json`, an OpenAPI 3.0.3 description of its OCS routes generated from +the controllers' attributes and docblocks by `composer run openapi` +(`nextcloud/openapi-extractor`). The document's `info.version` is owned by the release +workflow. The `openapi` workflow (`.github/workflows/openapi.yml`) MUST regenerate the document +on every pull request and fail when the committed file differs from the generated one, so a +route added without regenerating the file is caught. + +#### Scenario: A route added without regenerating the document fails CI + +@e2e exclude a CI property, not a user path; enforced by .github/workflows/openapi.yml. + +- **GIVEN** a pull request adds an `#[ApiRoute]` method and leaves `openapi.json` unchanged +- **WHEN** the `openapi` workflow runs `composer run openapi` +- **THEN** the working tree MUST differ from the commit +- **AND** the workflow MUST fail and show the missing paths + +#### Scenario: An integrator reads the routes from the package + +@e2e exclude a static file in the app package; no browser path. + +- **GIVEN** an installed Versioniq +- **WHEN** an integrator opens `openapi.json` in the app directory +- **THEN** it MUST list each OCS route under `/ocs/v2.php/apps/versioniq/api/` with its parameters and responses diff --git a/openspec/specs/migration-safety/spec.md b/openspec/specs/migration-safety/spec.md index 867e9ef..1cf2f5c 100644 --- a/openspec/specs/migration-safety/spec.md +++ b/openspec/specs/migration-safety/spec.md @@ -101,3 +101,60 @@ After every successful finalize, the system MUST record `lkg.{appId}` (JSON: `ve - GIVEN installed 2.6.0 (broken) and `lkg.openregister` = 2.5.0 - WHEN the admin clicks "Roll back to last known good" - THEN the standard install flow for 2.5.0 MUST start, presenting the downgrade dialog with the migration diff + +### Requirement: An App Store package is verified against Nextcloud code signing before install [MVP] + +Before an App Store release is downloaded, the system MUST check the release certificate +against the Nextcloud code-signing root (`resources/codesigning/root.crt`) and its revocation +list (`root.crl`): the CRL signature MUST validate, the certificate serial MUST NOT be revoked, +the certificate MUST be issued by the trusted root, and its common name MUST equal the app id. +After the download the system MUST verify the archive's SHA-512 signature with that +certificate's public key. Any failure MUST stop the install before a file is swapped. A forge +release carries no signature; it is checked by SHA-256 instead (external-sources, artifact-cache). +Code: `lib/Service/SelectedReleaseInstallerService.php` (`verifyCertificate`, the +`openssl_verify` check). + +#### Scenario: A certificate issued to another app is refused + +@e2e exclude needs a release signed by the Nextcloud code-signing root for a different app id, which CI cannot produce. + +- **GIVEN** an App Store release of `openregister` whose certificate CN is `calendar` +- **WHEN** the admin installs that version +- **THEN** the install MUST fail with "App with id openregister has a cert issued to calendar" +- **AND** no file of the installed app MUST change + +#### Scenario: A tampered archive is refused + +@e2e exclude needs a store archive altered in transit; the signature step is a single openssl_verify call on the downloaded bytes. + +- **GIVEN** a valid certificate and an archive whose bytes do not match the release signature +- **WHEN** the install verifies the download +- **THEN** the install MUST fail with "Release signature verification failed." +- **AND** no file of the installed app MUST change + +### Requirement: Maintenance mode is held around a real install [MVP] + +For a real install, the system MUST switch Nextcloud maintenance mode on before it touches the +app's files when maintenance mode was off, and MUST switch it off again when the install ends, +whether it succeeded, failed or threw. When maintenance mode was already on, the system MUST +leave it on. A dry run MUST NOT switch maintenance mode on, so a preview never locks users out +(#427). This holds for installs from the Apps tab, `occ versioniq:install` and the +automatic-update job, which share `InstallerService`. Code: `lib/Service/InstallerService.php` +(`$maintenanceWasSet` and the `finally` block). + +#### Scenario: A real install takes and releases maintenance mode + +@e2e exclude maintenance mode locks every other request on the shared CI instance, including the test's own. No unit test asserts the on and off pair yet (InstallerServiceTest only runs with maintenance already on); listed as a gap in the PR. + +- **GIVEN** maintenance mode is off +- **WHEN** the admin installs another version of an app and the install fails halfway +- **THEN** maintenance mode MUST have been on while files were swapped +- **AND** maintenance mode MUST be off again when the response returns + +#### Scenario: A dry run leaves maintenance mode alone + +@e2e exclude covered by tests/unit/Service/InstallerDryRunSideEffectsTest.php. + +- **GIVEN** maintenance mode is off +- **WHEN** the admin runs a dry run of an install +- **THEN** maintenance mode MUST NOT be switched on at any point diff --git a/openspec/specs/security-advisory-correlation/spec.md b/openspec/specs/security-advisory-correlation/spec.md index 53d6470..fcb456f 100644 --- a/openspec/specs/security-advisory-correlation/spec.md +++ b/openspec/specs/security-advisory-correlation/spec.md @@ -1,7 +1,7 @@ # security-advisory-correlation Specification ## Purpose -TBD - created by archiving change security-advisory-correlation. Update Purpose after archive. +An administrator learns which installed or pinned app versions are affected by a published security advisory, without leaving Nextcloud. The system reads Nextcloud's advisory feed and the forge advisories of bound sources on a schedule the admin sets, matches each advisory to the installed version per release branch, notifies admins of new advisories that affect them and sends a weekly digest of the rest. It never changes a version on its own. ## Requirements ### Requirement: The installed/pinned version is correlated against known security advisories @@ -72,3 +72,83 @@ critical and a low advisory do not look the same. - **GIVEN** an app with a low and a critical advisory - **WHEN** the Apps tab renders its card - **THEN** the advisory badge MUST read the state followed by "Critical" + + +### Requirement: Advisories are matched per release branch + +When an advisory lists patched versions, the system MUST decide whether the installed version is +affected from that list, per `major.minor` branch, and not from the advisory's range text: when +the installed version's own branch has a patch listed, that patch alone decides (at or above it +is fixed, below it is affected); a branch with no patch listed falls through to the nearest +higher patch in the same major; a version newer than every patch for its major is not affected. +An advisory without a patched-version list MUST keep the range evaluation. Code: +`lib/Service/Advisory/BranchAwareRange.php` (`resolvePatch`), called from +`lib/Service/Advisory/AdvisoryService.php`. + +#### Scenario: A backported fix on a maintained branch reads fixed + +@e2e exclude needs a live feed fixture; covered by tests/unit/Service/Advisory/BranchAwareRangeTest.php, which sweeps the committed feed corpus (0 false positives in 458 probes, 0 misses in 412). + +- **GIVEN** an advisory with patched versions 21.1.10, 22.0.11 and 23.0.3 +- **WHEN** the installed version is 22.0.11 +- **THEN** the app MUST NOT be reported as affected +- **AND** an installed 22.0.10 MUST be reported as affected, with 22.0.11 as the fix + +#### Scenario: Several lower bounds do not hide an affected version + +@e2e exclude covered by tests/unit/Service/Advisory/BranchAwareRangeTest.php. + +- **GIVEN** an advisory whose range reads ">= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0" and whose patched versions are 3.7.25, 5.5.16, 5.6.20 and 5.7.13 +- **WHEN** the installed version is 3.6.0 +- **THEN** the app MUST be reported as affected + +### Requirement: The admin sets how often advisories are checked + +The advisory refresh job MUST run every N hours, where N is the interval the admin saved, 6 by +default, between 1 and 24. The Apps tab MUST offer the interval in its security advisory +checks settings, reading the bounds from `GET /api/advisory/settings`. `PUT +/api/advisory/settings` MUST be admin-only and password-confirmed, and MUST refuse a value that +is not a number or lies outside 1 to 24 with HTTP 400. The job reads the interval when it is +constructed, so a change applies from the next run. Before the first check, the freshness line +MUST name the saved interval. Code: `lib/Service/Advisory/AdvisorySettingsStore.php`, +`lib/BackgroundJob/AdvisoryRefreshJob.php`, `lib/Controller/ApiController.php` +(`advisorySettings`, `updateAdvisorySettings`), `src/App.vue` (`advisory-interval`), +`src/utils/advisoryFreshness.ts`. + +#### Scenario: The admin changes the interval + +@e2e tests/e2e/advisories.spec.ts + +- **GIVEN** the admin opens the security advisory checks settings +- **WHEN** they set the interval to 12 hours and save +- **THEN** `GET /api/advisory/settings` MUST return `intervalHours` 12 +- **AND** a request for 30 hours MUST be refused with HTTP 400 and leave 12 stored + +### Requirement: Non-urgent advisories arrive in a weekly digest + +Once a week the system MUST send every admin one `advisory_digest` notification that counts the +apps with published advisories that do not affect the installed version, and the advisories in +total. The digest MUST NOT be sent when it is switched off, when one went out in the last seven +days, or when there is nothing to report; a week with nothing to report MUST NOT start the +seven-day wait. A failed dispatch MUST NOT count as sent. The digest is on by default, and the +admin switches it in the security advisory checks settings (`digestEnabled`). The notifier MUST +render the subject as "Weekly security advisory digest". Code: +`lib/Service/Advisory/AdvisoryDigestNotifier.php` (`sendIfDue`), called from +`lib/BackgroundJob/AdvisoryRefreshJob.php`, rendered by `lib/Notification/Notifier.php`. + +#### Scenario: The digest goes out once a week + +@e2e exclude the digest fires from a background job on a seven-day clock; covered by tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php. + +- **GIVEN** the digest is on, none was sent in the last seven days, and two apps have advisories that do not affect their installed version +- **WHEN** the advisory refresh job runs +- **THEN** every admin MUST receive one "Weekly security advisory digest" notification naming 2 apps +- **AND** the next run within seven days MUST NOT send another + +#### Scenario: A switched-off digest stays silent + +@e2e exclude covered by tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php. + +- **GIVEN** the admin switched the digest off +- **WHEN** the advisory refresh job runs +- **THEN** no `advisory_digest` notification MUST be sent diff --git a/openspec/specs/version-management/spec.md b/openspec/specs/version-management/spec.md index 863d92e..708e024 100644 --- a/openspec/specs/version-management/spec.md +++ b/openspec/specs/version-management/spec.md @@ -470,6 +470,63 @@ non-delegable admin setting and every endpoint already refuses a non-admin. - **WHEN** the Apps tab renders its card - **THEN** the card MUST show "Shipped with Nextcloud" and "Source: App Store" +### Requirement: The app list and the version list can be filtered + +The Apps tab MUST offer a text filter over the installed app list that keeps an app when its +label or its id contains the typed text, case-insensitive, after the core-apps and updates +filters have run. A filter panel MUST let the admin hide core apps. The version list of the +selected app MUST offer its own text filter that keeps a version whose number contains the +typed text, and MUST say "No versions match your filter." when none does. Both filters run in +the browser over lists already loaded; no request is sent. Code: `src/App.vue` (`filteredApps`, +`filteredVersions`, the `app-filter` input and the "Filter versions" input). + +#### Scenario: Filtering the app list by name or id + +@e2e exclude the filters are pure computed properties over the loaded list; an e2e run would need a fixed set of installed apps the shared CI instance does not guarantee. + +- **GIVEN** the Apps tab lists `calendar`, `contacts` and `openregister` +- **WHEN** the admin types `REG` in the app search field +- **THEN** only `openregister` MUST stay in the list +- **AND** choosing "Hide core apps" MUST also drop every app marked as core + +#### Scenario: Filtering the version list + +@e2e exclude covered by the computed filter in src/App.vue; the version list depends on live App Store data. + +- **GIVEN** the selected app lists versions 2.3.0, 2.4.1 and 3.0.0 +- **WHEN** the admin types `2.4` in "Filter versions" +- **THEN** only 2.4.1 MUST stay in the list +- **AND** typing `9.9` MUST show "No versions match your filter." + +### Requirement: The admin page works with a keyboard and a screen reader + +The admin page MUST start with a "Skip to main content" link that is visible on focus and +moves focus to the page content (`#versioniq-main`). The section tabs MUST be a WAI-ARIA +tablist named "Versioniq sections": each tab carries `role="tab"`, `aria-selected`, +`aria-controls` and a roving `tabindex`, and Left, Right, Home and End move between tabs. The +app detail sections MUST use the same tablist pattern. Every text input MUST carry an +accessible name, and every data table MUST mark its column headers with `scope="col"`. Code: +`src/App.vue` (skip link, `onTabKeydown`, the tablists), `src/components/HistoryPanel.vue` and +`src/components/IntegrationsPanel.vue` (table headers). + +#### Scenario: Arrow keys move between tabs + +@e2e tests/e2e/shell.spec.ts + +- **GIVEN** the admin opens the Versioniq settings page and focuses the Apps tab +- **WHEN** they press the Right arrow key +- **THEN** the Advisories tab MUST read `aria-selected="true"` +- **AND** its tab panel MUST be shown + +#### Scenario: The skip link reaches the content + +@e2e exclude the skip link is a plain anchor to #versioniq-main; asserted by reading the template, no browser behaviour of our own to test. + +- **GIVEN** a keyboard user lands on the settings page +- **WHEN** they press Tab once and then Enter +- **THEN** the "Skip to main content" link MUST have been visible +- **AND** focus MUST move to the page content + ## User Stories 1. As a Nextcloud admin, I want to roll back an app to a previous version so that I can recover from a broken update.