diff --git a/lib/Service/Connection/ConnectionObservations.php b/lib/Service/Connection/ConnectionObservations.php index e903ffda9..685baf2a7 100644 --- a/lib/Service/Connection/ConnectionObservations.php +++ b/lib/Service/Connection/ConnectionObservations.php @@ -84,17 +84,18 @@ public function breachLookup(?int $httpStatus): array { * enabled the refresh stands alone, and the row waits for the next drain. * * @param int $enabledSinks How many sinks are enabled after the save. + * @param int $sinks How many sinks exist after the save, enabled or not. * * @return array{0: string, 1: string}|null The status and the message, or null. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ - public function siemSinksChanged(int $enabledSinks): ?array { + public function siemSinksChanged(int $enabledSinks, int $sinks): ?array { if ($enabledSinks > 0) { return null; } - return $this->noSinkEnabled(); + return $this->noSinkEnabled(sinks: $sinks); }//end siemSinksChanged() /** @@ -106,14 +107,15 @@ public function siemSinksChanged(int $enabledSinks): ?array { * @param int $enabledSinks How many sinks are enabled. * @param array $delivered Per sink the drain delivered to: its host, and * whether its last delivery went through. + * @param int $sinks How many sinks exist, enabled or not. * * @return array{0: string, 1: string}|null The status and the message, or null when the drain met nothing. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ - public function siemDrain(int $enabledSinks, array $delivered): ?array { + public function siemDrain(int $enabledSinks, array $delivered, int $sinks): ?array { if ($enabledSinks === 0) { - return $this->noSinkEnabled(); + return $this->noSinkEnabled(sinks: $sinks); } $total = count($delivered); @@ -219,9 +221,20 @@ private function atHost(string $host): string { /** * The report for an instance where no sink is enabled. * + * Sinks that exist and are all switched off are a choice an admin made, so + * they read `disabled` (hydra connection-registry D4, D12 item 9). No sink + * at all is a step nobody took yet, so it stays `unconfigured`. Neither + * message names a host: there is no delivery to name one from. + * + * @param int $sinks How many sinks exist, enabled or not. + * * @return array{0: string, 1: string} */ - private function noSinkEnabled(): array { - return ['unconfigured', 'No SIEM sink is switched on. Add one under SIEM audit export.']; + private function noSinkEnabled(int $sinks): array { + if ($sinks > 0) { + return ['disabled', 'Every SIEM sink is switched off, so no audit event is forwarded.']; + } + + return ['unconfigured', 'No SIEM sink is added yet. Add one under SIEM audit export.']; }//end noSinkEnabled() }//end class diff --git a/lib/Service/Connection/ConnectionReporter.php b/lib/Service/Connection/ConnectionReporter.php index 480fdbc4b..276d5d341 100644 --- a/lib/Service/Connection/ConnectionReporter.php +++ b/lib/Service/Connection/ConnectionReporter.php @@ -143,8 +143,8 @@ public function __construct( /** * After an admin save wrote `breach_check_enabled`: ask integriq to look again. * - * No report follows. Integriq reads the switch itself (rule 5), and a - * lookup reports once a user checks a password. + * No report follows. Integriq reads the `hibp` switch itself (rule 2b), and + * a lookup reports once a user checks a password. * * @return bool True when the refresh was sent. * @@ -173,23 +173,32 @@ public function reportBreachLookup(?int $httpStatus): bool { /** * After a sink create, change or delete: refresh, then report when no sink is left on. * - * The count is only taken when integriq is installed, so without it the - * save costs no extra query. + * The counts are only taken when integriq is installed, so without it the + * save costs no extra query. All sinks are only counted when none is + * enabled, to tell switched off from never added. * * @param callable(): int $enabledSinkCount Counts the sinks that are enabled after the save. + * @param callable(): int $sinkCount Counts every sink after the save, enabled or not. * * @return bool True when a report was sent. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ - public function siemSinksChanged(callable $enabledSinkCount): bool { + public function siemSinksChanged(callable $enabledSinkCount, callable $sinkCount): bool { if ($this->refresh(key: self::KEY_SIEM) === false) { return false; } return $this->reportObserved( key: self::KEY_SIEM, - observe: fn (): ?array => $this->observations->siemSinksChanged(enabledSinks: $enabledSinkCount()) + observe: function () use ($enabledSinkCount, $sinkCount): ?array { + $enabled = $enabledSinkCount(); + + return $this->observations->siemSinksChanged( + enabledSinks: $enabled, + sinks: $this->countSinksWhenNoneEnabled(enabled: $enabled, sinkCount: $sinkCount) + ); + } ); }//end siemSinksChanged() @@ -198,12 +207,13 @@ public function siemSinksChanged(callable $enabledSinkCount): bool { * * @param int $enabledSinks How many sinks are enabled. * @param array $attemptedSinks The sinks this drain tried to deliver to, after the attempt. + * @param callable(): int $sinkCount Counts every sink, enabled or not. Only called when none is enabled. * * @return bool True when a report was sent. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met */ - public function reportSiemDrain(int $enabledSinks, array $attemptedSinks): bool { + public function reportSiemDrain(int $enabledSinks, array $attemptedSinks, callable $sinkCount): bool { return $this->reportObserved( key: self::KEY_SIEM, observe: fn (): ?array => $this->observations->siemDrain( @@ -214,11 +224,30 @@ public function reportSiemDrain(int $enabledSinks, array $attemptedSinks): bool 'ok' => $sink->getLastDeliveryStatus() === 'ok', ], array_values($attemptedSinks) - ) + ), + sinks: $this->countSinksWhenNoneEnabled(enabled: $enabledSinks, sinkCount: $sinkCount) ) ); }//end reportSiemDrain() + /** + * Every sink, counted only when none is enabled; otherwise the enabled count stands in. + * + * With a sink enabled the total cannot change the report, so the query is skipped. + * + * @param int $enabled How many sinks are enabled. + * @param callable(): int $sinkCount Counts every sink. + * + * @return int + */ + private function countSinksWhenNoneEnabled(int $enabled, callable $sinkCount): int { + if ($enabled > 0) { + return $enabled; + } + + return $sinkCount(); + }//end countSinksWhenNoneEnabled() + /** * The HTTP status a failed call still carries, for {@see reportBreachLookup()}. * diff --git a/lib/Service/SiemService.php b/lib/Service/SiemService.php index 1d82075be..07b3f210c 100644 --- a/lib/Service/SiemService.php +++ b/lib/Service/SiemService.php @@ -217,7 +217,8 @@ public function deliverDue(): int { $this->connectionReporter?->reportSiemDrain( enabledSinks: count($enabledSinks), - attemptedSinks: array_values($attempted) + attemptedSinks: array_values($attempted), + sinkCount: fn (): int => count($this->sinkMapper->findAll()) ); return $delivered; diff --git a/lib/Service/SiemSinkService.php b/lib/Service/SiemSinkService.php index 452b87fd4..6658ff307 100644 --- a/lib/Service/SiemSinkService.php +++ b/lib/Service/SiemSinkService.php @@ -281,7 +281,8 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void { */ private function reportSinksChanged(): void { $this->connectionReporter?->siemSinksChanged( - enabledSinkCount: fn (): int => count($this->sinkMapper->findEnabled()) + enabledSinkCount: fn (): int => count($this->sinkMapper->findEnabled()), + sinkCount: fn (): int => count($this->sinkMapper->findAll()) ); }//end reportSinksChanged() }//end class diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json index 221b9705d..e27c2577e 100644 --- a/lib/Settings/connections.json +++ b/lib/Settings/connections.json @@ -7,8 +7,11 @@ "description": "Checks password hash prefixes against Have I Been Pwned, once an admin switches it on and a user opts in.", "order": 10, "settingsUrl": "/settings/admin/keepiq#section-breach-check", - "requiredConfig": ["breach_check_enabled"], - "unconfiguredMessage": "Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings." + "switch": { + "configKey": "breach_check_enabled" + }, + "disabledMessage": "Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings.", + "unconfiguredMessage": "Not checked yet. Keepiq reports here after the next password check reaches Have I Been Pwned." }, { "key": "siem", diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md index ac90d28b1..faffda697 100644 --- a/openspec/changes/adopt-connection-registry/design.md +++ b/openspec/changes/adopt-connection-registry/design.md @@ -8,10 +8,10 @@ Each candidate was checked against the code on `development` on 2026-09-14. | Key | Declared as | Why | |---|---|---| -| `hibp` | `requiredConfig: ["breach_check_enabled"]` | `BreachProxyController::range()` refuses every lookup with 403 while the key is off. Nothing else gates the call. | +| `hibp` | `switch: {"configKey": "breach_check_enabled"}` | `BreachProxyController::range()` refuses every lookup with 403 while the key is off. Nothing else gates the call. | | `siem` | `reportedOnly: true` | `SiemService::deliverDue()` drains every enabled sink in `keepiq_siem_sinks`. Sinks are records, not app config. | -**Why a boolean key is honest here.** `AdminSettingsService` stores `breach_check_enabled` with `setValueBool`. Integriq's `ConnectionConfigReader::readAnyType()` reads a typed key with `getValueBool`, and since hydra#676 a `false` counts as empty. A switched-off check therefore reads Not configured, and a switched-on one reads Configured with "Required settings are filled." until the first lookup reports. Verified against `integriq/lib/Service/ConnectionConfigReader.php` on `development`. +**Why a boolean key is honest here.** `AdminSettingsService` stores `breach_check_enabled` with `setValueBool`. Integriq's `ConnectionConfigReader::readAnyType()` reads a typed key with `getValueBool`, and since hydra#676 a `false` counts as empty. Since hydra#677 the key is the row's `switch`, not a required setting: a switched-off check reads `disabled` with the declared `disabledMessage`, and a switched-on one reads Not configured with "Not checked yet" until the first lookup reports. A filled switch says the check may run, not that Have I Been Pwned answered. **Why no adapter on `hibp`.** The upstream is a fixed constant, `https://api.pwnedpasswords.com/range/`. There is no mock to select, so rule 3 has nothing to read. @@ -36,13 +36,14 @@ Each candidate was checked against the code on `development` on 2026-09-14. | answered anything else | `error` | "Have I Been Pwned answered HTTP {n} on the last range lookup." | | did not answer | `error` | "The last range lookup got no answer from Have I Been Pwned." | -**SIEM, on a sink create, change or delete.** A refresh for `siem`. When no enabled sink is left, a report `unconfigured`: "No SIEM sink is switched on. Add one under SIEM audit export." Otherwise the refresh alone, so the row reads the declared "Not checked yet" until the next drain delivers. +**SIEM, on a sink create, change or delete.** A refresh for `siem`. When no enabled sink is left and sinks still exist, a report `disabled`: "Every SIEM sink is switched off, so no audit event is forwarded." When no sink exists at all, a report `unconfigured`: "No SIEM sink is added yet. Add one under SIEM audit export." The row has no `switch`, because sinks are records, so Keepiq reports `disabled` itself (hydra connection-registry D4). All sinks are counted only when none is enabled. Otherwise the refresh alone, so the row reads the declared "Not checked yet" until the next drain delivers. **SIEM, after a drain** (`DeliverSiemEventsJob`, every 60 seconds). The report looks only at sinks the drain tried to deliver to in this run. A sink's older `lastDeliveryStatus` is not used: it would bring back an error from before a save, which is exactly what hydra#674 retires. | Sinks the drain delivered to | Status | |---|---| -| none, and no sink is enabled | `unconfigured` | +| none, no sink is enabled, and sinks exist | `disabled` | +| none, and no sink exists | `unconfigured` | | none, while sinks are enabled | nothing | | all took it | `configured` | | some took it | `limited`, naming the first host that failed | @@ -60,14 +61,14 @@ Each candidate was checked against the code on `development` on 2026-09-14. - `src/manifest.d/80-connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings. - Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: keepiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`. -- `src/services/connectionRegistry.js` holds the two formatters and `openIntegriqConnections`. -- `App.vue` passes the formatters through CnAppRoot's `formatters` prop, and merges the handler into the `customComponents` it passes, because CnIndexPage resolves a header action's handler against `customComponents`. It passed no formatters before this change. +- `src/services/connectionRegistry.js` holds `openIntegriqConnections`. +- `App.vue` passes no `formatters`, because CnAppRoot supplies the two built-ins, and merges the handler into the `customComponents` it passes, because CnIndexPage resolves a header action's handler against `customComponents`. **Keepiq's own navigation rail.** Keepiq renders `KeepiqAppNav` in CnAppRoot's `#menu` slot, because CnAppNav cannot draw the vault folder tree. That rail read only `route`, `href` and `action`. It dropped `query`, so the menu would have opened the page with no preset and listed every app's rows. It also ignored `permission` and `visibleIf`, so the entry would have shown to every user and without integriq. `src/utils/navEntries.js` now holds both rules, taken from CnAppNav: `menuEntryTo()` passes `query` into the route, and `isMenuEntryVisible()` checks `visibleIf.appInstalled` against `OC.appswebroots` and `permission: admin` against the instance admin flag. No existing entry declares either field, so nothing else in the rail changes. **Why `/settings/integrations` does not break ADR-004.** The rule forbids routing an admin settings component, such as `AdminRoot.vue`, inside the app. This route renders a CnIndexPage over integriq's `app_connection`, whose schema grants read access to admins only. The admin settings themselves stay in `AdminSettings.php`. The `hydra-gate-admin-router` check reads `src/router/index.js`, which Keepiq does not have: routes come from the manifest. -**Formatters.** The installed `@conduction/nextcloud-vue` 2.41.1 ships no `connectionStatus` built-in, so Keepiq carries a local copy with all six labels, `limited` included. +**Formatters.** `@conduction/nextcloud-vue` 3.2.0 ships `connectionStatus` and `connectionSettingsLabel` as built-ins, `disabled` included (nextcloud-vue#1173). Keepiq carried a local copy while it pinned 2.41.1, and dropped it on moving to 3.2.0. ## D4. Contract misfits diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md index c266e21a9..6fce9ff3c 100644 --- a/openspec/changes/adopt-connection-registry/proposal.md +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -16,17 +16,17 @@ Hydra change `connection-registry` (hydra#667, amended in hydra#673, hydra#674 a ## What changes - New `lib/Settings/connections.json` with two connections: `hibp` and `siem`. -- `hibp` requires `breach_check_enabled`. The key is a boolean, and integriq reads a stored `false` as empty (amendment 6), so a switched-off check reads Not configured. +- `hibp` declares `breach_check_enabled` as its `switch`. The key is a boolean, and integriq reads a stored `false` as empty (amendment 6), so a switched-off check reads Switched off (amendment 9). - `siem` is `reportedOnly`. The sinks are records, not settings, so a static file cannot list them. One row speaks for the whole family (D12, "Still out"). - The Breach checking and SIEM audit export sections get stable ids: `section-breach-check` and `section-siem`. - Saving `breach_check_enabled` sends `ConnectionRefreshRequestedEvent` for `hibp`. -- Creating, changing or deleting a sink sends a refresh for `siem`, then reports Not configured when no sink is switched on. +- Creating, changing or deleting a sink sends a refresh for `siem`, then reports Switched off when sinks exist and none is on, or Not configured when there is no sink. - A range lookup that reaches Have I Been Pwned reports its outcome. A SIEM drain that delivered to at least one sink reports the outcome over those sinks. Both are throttled: the same status at most once an hour, a new status at most once every five minutes. - A report names a status code or a host. It never carries a hash prefix, a password, a sink URL path, a token or an exception message. - An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=keepiq`, admin only, and only shown when integriq is installed. - Keepiq's own navigation rail learns to honour a menu entry's `query`, `permission: admin` and `visibleIf.appInstalled`. It ignored all three before, so the preset would not have reached the page. - Add integration opens `/apps/integriq/connections?app=keepiq&link=1`. -- Local `connectionStatus` and `connectionSettingsLabel` formatters with all six statuses, and the strings in English and Dutch. +- The `connectionStatus` and `connectionSettingsLabel` formatters come from `@conduction/nextcloud-vue` 3.2.0, which labels all seven statuses. The page strings are in English and Dutch. ## Depends on diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md index 3ec6035b1..8be3660a7 100644 --- a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md +++ b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md @@ -13,7 +13,7 @@ Admins see Keepiq's outside connections on one page, with a status Keepiq can ba ### Requirement: REQ-KEEPIQ-CONN-001 Keepiq declares its outside connections in one static file -Keepiq SHALL declare `hibp` and `siem` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The file MUST validate against integriq's `connections.schema.json`, and its `app` MUST equal the id in `appinfo/info.xml`. The `hibp` entry SHALL require `breach_check_enabled`. The `siem` entry SHALL be `reportedOnly`, because the sinks are records and not settings. Every `settingsUrl` SHALL point at a section id that exists in the Keepiq admin settings. +Keepiq SHALL declare `hibp` and `siem` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The file MUST validate against integriq's `connections.schema.json`, and its `app` MUST equal the id in `appinfo/info.xml`. The `hibp` entry SHALL declare `breach_check_enabled` as its `switch` and SHALL require no setting, so a switched-off check reads `disabled` (hydra connection-registry D12 item 9). The `siem` entry SHALL be `reportedOnly`, because the sinks are records and not settings. Every `settingsUrl` SHALL point at a section id that exists in the Keepiq admin settings. #### Scenario: The declaration names this app and passes integriq's schema @e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php validates it against the schema, and checks the app id, unique keys and the anchors. @@ -25,17 +25,17 @@ Keepiq SHALL declare `hibp` and `siem` in `lib/Settings/connections.json` in the - **AND** every key SHALL be unique - **AND** every `#section-...` anchor SHALL be an id in a settings section component -#### Scenario: A switched-off breach check reads not configured +#### Scenario: A switched-off breach check reads switched off @e2e tests/e2e/workflows/integrations-page.spec.ts - **GIVEN** integriq has synced Keepiq's declaration - **WHEN** `breach_check_enabled` holds `false` -- **THEN** the Breach check row SHALL read Not configured with the declared message -- **AND** when an admin switches breach checking on, the row SHALL read Configured +- **THEN** the Breach check row SHALL read `disabled` with the declared message +- **AND** when an admin switches breach checking on, the row SHALL read Not configured with "Not checked yet" until a lookup reports ### Requirement: REQ-KEEPIQ-CONN-002 A save asks integriq to look again, and a lookup or a drain reports what it met -When an admin save writes `breach_check_enabled`, Keepiq SHALL send `ConnectionRefreshRequestedEvent` with app `keepiq` and key `hibp`. When an admin creates, changes or deletes a SIEM sink, Keepiq SHALL send a refresh for `siem`, and then report `unconfigured` when no sink is switched on. A refresh SHALL come before any report for the same key (hydra REQ-CONN-004, hydra#674). A range lookup that reaches Have I Been Pwned SHALL report `configured` on a 2xx answer, `limited` on HTTP 429 and `error` on any other answer or no answer. A SIEM drain SHALL report over the sinks it delivered to in that run: all took it as `configured`, some as `limited`, none as `error`. A drain that delivered to no sink SHALL report nothing. The same status SHALL be reported at most once an hour and a new status at most once every five minutes, and a save SHALL clear that memory. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. +When an admin save writes `breach_check_enabled`, Keepiq SHALL send `ConnectionRefreshRequestedEvent` with app `keepiq` and key `hibp`. When an admin creates, changes or deletes a SIEM sink, Keepiq SHALL send a refresh for `siem`. When no sink is switched on afterwards, it SHALL then report `disabled` while sinks exist, and `unconfigured` when none does. A `disabled` report SHALL name no host. A refresh SHALL come before any report for the same key (hydra REQ-CONN-004, hydra#674). A range lookup that reaches Have I Been Pwned SHALL report `configured` on a 2xx answer, `limited` on HTTP 429 and `error` on any other answer or no answer. A SIEM drain SHALL report over the sinks it delivered to in that run: all took it as `configured`, some as `limited`, none as `error`. A drain that delivered to no sink SHALL report nothing. The same status SHALL be reported at most once an hour and a new status at most once every five minutes, and a save SHALL clear that memory. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. #### Scenario: Saving the breach check switch asks for a refresh @e2e exclude The event is not observable from a browser; tests/Unit/Controller/SettingsControllerConnectionRefreshTest.php asserts the refresh and the unchanged response. @@ -49,9 +49,17 @@ When an admin save writes `breach_check_enabled`, Keepiq SHALL send `ConnectionR @e2e exclude A sink change needs a SIEM receiver the CI instance does not have; tests/Unit/Service/Connection/ConnectionReporterTest.php asserts the order, and tests/Unit/Service/SiemConnectionReportCallersTest.php that the sink service hands it over. - **GIVEN** integriq is installed -- **WHEN** an admin deletes the only enabled SIEM sink +- **WHEN** an admin deletes the only SIEM sink - **THEN** Keepiq SHALL send a refresh for `siem` -- **AND** then a report `unconfigured` saying no sink is switched on +- **AND** then a report `unconfigured` saying no sink is added yet + +#### Scenario: Switching off the last enabled sink reports disabled +@e2e exclude A sink change needs a SIEM receiver the CI instance does not have; tests/Unit/Service/SiemConnectionReportCallersTest.php and tests/Unit/Service/Connection/ConnectionReporterTest.php assert the refresh, the `disabled` report and its host-free message. + +- **GIVEN** integriq is installed and two SIEM sinks exist +- **WHEN** an admin switches off the last one that was enabled +- **THEN** Keepiq SHALL send a refresh for `siem` +- **AND** then a report `disabled` that names no host #### Scenario: A drain where some sinks fail reads limited @e2e exclude A drain needs reachable and unreachable receivers; tests/Unit/Service/Connection/ConnectionObservationsTest.php drives the outcomes. @@ -99,7 +107,7 @@ A connection report is read by every admin, and integriq stores it in OpenRegist ### Requirement: REQ-KEEPIQ-CONN-004 An admin reads the connections on an Integrations page -Keepiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `keepiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. Keepiq's navigation rail SHALL pass the entry's `query` into the route and SHALL honour `permission: admin` and `visibleIf.appInstalled`. The status column SHALL name all six statuses, `limited` included. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=keepiq&link=1`. +Keepiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `keepiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. Keepiq's navigation rail SHALL pass the entry's `query` into the route and SHALL honour `permission: admin` and `visibleIf.appInstalled`. The status column SHALL name all seven statuses, `limited` and `disabled` included, through the `connectionStatus` formatter `@conduction/nextcloud-vue` ships. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=keepiq&link=1`. #### Scenario: The page lists only the rows of keepiq @e2e tests/e2e/workflows/integrations-page.spec.ts @@ -124,7 +132,7 @@ Keepiq SHALL render an `index` page at `/settings/integrations` over `integriq/a - **THEN** Keepiq's navigation rail SHALL NOT render the entry #### Scenario: A connection that works in part reads Limited -@e2e exclude Only a rate-limited lookup or a partly failing drain produces limited; tests/vitest/connectionRegistry.spec.js asserts the label in English and Dutch. +@e2e exclude Only a rate-limited lookup or a partly failing drain produces limited; tests/vitest/connectionRegistry.spec.js asserts the status column uses the library's built-in connectionStatus, whose labels nextcloud-vue's tests/utils/builtInFormatters.spec.js (formatConnectionStatus) asserts, with Beperkt in the library's l10n/nl.json. - **GIVEN** a row whose status is `limited` - **WHEN** the page renders it diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md index 621a6f913..116c34023 100644 --- a/openspec/changes/adopt-connection-registry/tasks.md +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -29,6 +29,11 @@ - [x] 4.1 Write `tests/e2e/workflows/integrations-page.spec.ts`. - [x] 4.2 Install integriq in the CI `additional-apps`. -## 5. After integriq ships +## 5. Switch and built-in formatters (hydra#677) -- [ ] 5.1 Run the e2e spec against an instance with both apps, then archive this change. +- [x] 5.1 Declare `switch` on `hibp` in place of `requiredConfig`, and report `disabled` when every SIEM sink is switched off. +- [x] 5.2 Move `@conduction/nextcloud-vue` to the release with the built-in connection formatters and delete the local copy. + +## 6. After integriq ships + +- [ ] 6.1 Run the e2e spec against an instance with both apps, then archive this change. diff --git a/package-lock.json b/package-lock.json index 14c5e3d97..242c5f6e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "EUPL-1.2", "dependencies": { - "@conduction/nextcloud-vue": "^2.41.1", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", @@ -98,7 +98,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -187,7 +187,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/utils": "^2.5.0", @@ -200,7 +200,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.4.0", @@ -217,7 +217,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -227,7 +227,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hashery": "^1.5.1", @@ -238,7 +238,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -411,9 +411,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.41.1.tgz", - "integrity": "sha512-VpqYxtWoXMACha3Pnw6/vueuX2IUJlF8DLufKSpDVUQkN7mdWaFSy87EfvK/7THUcpYHrtGN5GnLX82MOlgNOg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-3.2.0.tgz", + "integrity": "sha512-jRKOE/xpLnsk9L8i2G6loifDJpRC+ORCsnfkpySDwAT3MRTriKDRXkc/lxfPHxXzXNeCJfiDPEEYbwFHFOUS9Q==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -430,11 +430,10 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nextcloud/dialogs": "^7.4.1", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/files": "^3.12.2", + "@nextcloud/files": "^4.0.0", "@nextcloud/notify_push": "^1.4.0", "@nextcloud/password-confirmation": "^6.1.0", "@toast-ui/editor": "^3.2.2", - "@types/react": "^18.0.0", "@uiw/codemirror-theme-github": "^4.25.8", "@vue-flow/background": "^1.3.2", "@vue-flow/core": "^1.48.2", @@ -444,6 +443,7 @@ "ajv-formats": "^3.0.1", "apexcharts": "^4.7.0", "codemirror": "^6.0.0", + "commander": "^14.0.3", "leaflet": "^1.9.0", "leaflet.markercluster": "^1.5.3", "linkifyjs": "^4.3.3", @@ -466,6 +466,7 @@ "@nextcloud/initial-state": "^2.2.0 || ^3.0.0", "@nextcloud/l10n": "^2.0.0 || ^3.0.0", "@nextcloud/router": "^2.0.0 || ^3.0.0", + "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/vue": "^9.0.0", "@vueuse/core": "^11.0.0 || ^14.0.0", "axe-core": "^4.10.0", @@ -474,20 +475,18 @@ "eslint": "^8.56.0 || ^9.0.0 || ^10.0.0", "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", - "marked": "^12.0.0", + "marked": ">=12 <19", "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", + "stylelint": "^17.9.1", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", "vue-material-design-icons": "^5.0.0" }, "peerDependenciesMeta": { - "axe-core": { - "optional": true - }, - "dexie": { + "@nextcloud/stylelint-config": { "optional": true }, - "dompurify": { + "axe-core": { "optional": true }, "eslint": { @@ -496,7 +495,7 @@ "eslint-plugin-vue": { "optional": true }, - "marked": { + "stylelint": { "optional": true }, "vue-eslint-parser": { @@ -504,6 +503,75 @@ } } }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", + "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing/node_modules/@nextcloud/files": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", + "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "cancelable-promise": "^4.3.1", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.1", + "webdav": "^5.8.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/sharing/node_modules/@nextcloud/initial-state": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz", + "integrity": "sha512-cV+HBdkQJGm8FxkBI5rFT/FbMNWNBvpbj6OPrg4Ae4YOOsQ15CL8InPOAw1t4XkOkQK2NEdUGQLVUz/19wXbdQ==", + "license": "GPL-3.0-or-later", + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -603,7 +671,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -648,7 +716,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -671,7 +739,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1656,7 +1724,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@lezer/common": { @@ -2185,7 +2253,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-3.2.2.tgz", "integrity": "sha512-5rr77fGK+zoa8yN+8zR43XbqyuN/yB2wSAy4vKE2F+hgAeOhpUAyChV+pfySDbP20t4s22DHgn7BDiZKsbNE3Q==", - "dev": true, + "devOptional": true, "license": "AGPL-3.0-or-later", "dependencies": { "stylelint-use-logical": "^2.1.3" @@ -2963,7 +3031,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2977,7 +3045,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2987,7 +3055,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4092,7 +4160,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -4312,22 +4380,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, "node_modules/@types/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", @@ -5500,7 +5552,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -5513,7 +5565,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5571,7 +5623,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/assertion-error": { @@ -5650,7 +5702,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5848,7 +5900,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6042,7 +6094,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cacheable/memory": "^2.2.0", @@ -6056,7 +6108,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -6079,7 +6131,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6290,7 +6342,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6303,14 +6355,14 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colord": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colorette": { @@ -6346,7 +6398,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -6431,7 +6482,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -6488,7 +6539,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6547,7 +6598,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -6904,7 +6955,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -6920,7 +6971,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "engines": { @@ -6934,7 +6985,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -6948,7 +6999,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -6974,7 +7025,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -7064,7 +7115,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/encoding": { @@ -7119,7 +7170,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7150,7 +7201,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -7762,7 +7813,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -7779,7 +7830,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -7861,7 +7912,7 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4.9.1" @@ -7871,7 +7922,7 @@ "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -7942,7 +7993,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8152,7 +8203,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -8251,7 +8302,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "global-prefix": "^3.0.0" @@ -8264,7 +8315,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ini": "^1.3.5", @@ -8279,7 +8330,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8305,7 +8356,7 @@ "version": "16.2.4", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -8327,7 +8378,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -8337,7 +8388,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/gopd": { @@ -8423,7 +8474,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^1.15.0" @@ -8532,7 +8583,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/hosted-git-info": { @@ -8605,7 +8656,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.10" @@ -8618,7 +8669,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -8639,7 +8690,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "peer": true, "engines": { @@ -8761,7 +8812,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -8798,7 +8849,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -8825,7 +8876,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -8895,7 +8946,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-buffer": { @@ -8944,7 +8995,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8977,7 +9028,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8987,7 +9038,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9012,7 +9063,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -9190,14 +9241,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9302,7 +9353,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -9332,7 +9383,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9342,7 +9393,7 @@ "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -9676,7 +9727,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/linkifyjs": { @@ -9728,7 +9779,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/longest-streak": { @@ -9876,7 +9927,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -10107,14 +10158,14 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/meow": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -10134,7 +10185,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -10586,7 +10637,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10600,7 +10651,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11302,7 +11353,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11493,7 +11544,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -11541,7 +11592,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -11865,7 +11916,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.8.1.tgz", "integrity": "sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -11882,7 +11933,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11890,7 +11941,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11961,7 +12012,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -11969,7 +12020,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -11987,7 +12038,7 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -12029,7 +12080,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/prebuild-install": { @@ -12323,7 +12374,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hookified": "^2.1.1" @@ -12336,7 +12387,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/quansync": { @@ -12365,7 +12416,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -12634,7 +12685,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -12666,7 +12717,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -12777,7 +12828,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13003,7 +13054,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -13065,7 +13116,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -13078,7 +13129,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -13382,7 +13433,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -13455,7 +13506,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -13586,7 +13637,7 @@ "version": "17.14.1", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -13646,7 +13697,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-html/-/stylelint-config-html-2.0.0.tgz", "integrity": "sha512-Lk1NPEdUxzHkPv3ehktjpiOk4MPaqQ3H8fkvhxdWlQpaZCm9ze0SoMbRQLqkUxEMfPE1G9/x968uxm/+d2njoA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -13664,7 +13715,7 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -13688,7 +13739,7 @@ "version": "17.0.1", "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-17.0.1.tgz", "integrity": "sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13713,7 +13764,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/stylelint-config-recommended-vue/-/stylelint-config-recommended-vue-1.6.1.tgz", "integrity": "sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13736,7 +13787,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "peer": true, "bin": { @@ -13750,7 +13801,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.2.0.tgz", "integrity": "sha512-6E79Bachv0Iz0gqRUZgdqdXCsiq26DWBWIBNHYtjTmAp3wJu6cp/I37VfW7BPntmh2puF3bY09XWl4HZGrLhzw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -13777,7 +13828,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13802,7 +13853,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13826,7 +13877,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13847,7 +13898,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/stylelint-use-logical/-/stylelint-use-logical-2.1.3.tgz", "integrity": "sha512-haPkgxKre+eSqr4IZJnHwNT/9/wICykeFZIaz7rZbe4SohTHkw7vBahMOrZJZpdny/EBVHAcPH2IBeoiUcZWWw==", - "dev": true, + "devOptional": true, "license": "CC0-1.0", "engines": { "node": ">=14.0.0" @@ -13860,7 +13911,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13884,7 +13935,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13907,7 +13958,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13927,7 +13978,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13951,7 +14002,7 @@ "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "flat-cache": "^6.1.23" @@ -13961,7 +14012,7 @@ "version": "6.1.23", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cacheable": "^2.5.0", @@ -13973,7 +14024,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -13983,7 +14034,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -14035,7 +14086,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^5.0.1", @@ -14052,7 +14103,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -14065,7 +14116,7 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -14091,7 +14142,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", - "dev": true + "devOptional": true }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -14110,7 +14161,7 @@ "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", @@ -14127,7 +14178,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -14137,7 +14188,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14152,7 +14203,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14333,7 +14384,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -14525,7 +14576,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -15721,7 +15772,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "signal-exit": "^4.0.1" diff --git a/package.json b/package.json index ed7c1392b..a37a3e827 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "libxmljs2": "^0.37.0" }, "dependencies": { - "@conduction/nextcloud-vue": "^2.41.1", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", diff --git a/src/App.vue b/src/App.vue index 515d48e57..b3b431469 100644 --- a/src/App.vue +++ b/src/App.vue @@ -94,7 +94,6 @@ :supportDialog="showSupportDialog" :manifest="manifest" :customComponents="shellCustomComponents" - :formatters="formatters" :pageTypes="pageTypes" :registry="registry" appId="keepiq" @@ -405,10 +404,7 @@ import { isPublicSurface, LOCK_ROUTE_NAME, } from './router/guards.js' -import { - createConnectionFormatters, - createConnectionHandlers, -} from './services/connectionRegistry.js' +import { createConnectionHandlers } from './services/connectionRegistry.js' import { useEncryptionSuiteStore } from './store/modules/encryptionSuite.js' import { useOfflineStore } from './store/modules/offline.js' import { useSessionStore } from './store/modules/session.js' @@ -513,17 +509,6 @@ export default { { value: '10min', label: ncT('keepiq', '10 minutes') }, { value: '30min', label: ncT('keepiq', '30 minutes') }, ], - - /** - * Named cell formatters merged over CnAppRoot's built-ins. - * `connectionStatus` and `connectionSettingsLabel` render the - * Integrations page (adopt-connection-registry); nextcloud-vue - * 2.41.1 ships neither as a built-in. Before this change the app - * passed no formatters at all. - */ - formatters: createConnectionFormatters((source) => - ncT('keepiq', source), - ), } }, diff --git a/src/services/connectionRegistry.js b/src/services/connectionRegistry.js index 73d50559e..4d69458ae 100644 --- a/src/services/connectionRegistry.js +++ b/src/services/connectionRegistry.js @@ -2,15 +2,14 @@ // Copyright (C) 2026 Conduction B.V. /** - * The Integrations page's two formatters and its Add integration handler. + * The Integrations page's Add integration handler. * * The rows on that page are integriq's `app_connection` objects (hydra change - * connection-registry, design D8). The installed @conduction/nextcloud-vue - * 2.41.1 ships neither formatter, so Keepiq carries this copy until a release - * with the built-ins is pinned. The names are the contract's, so the copies - * across the fleet stay interchangeable. + * connection-registry, design D8). Its two formatters, `connectionStatus` and + * `connectionSettingsLabel`, are built into @conduction/nextcloud-vue from + * 3.2.0, so CnAppRoot supplies them and Keepiq no longer carries a copy. * - * Pure: the translator, the URL builder and the navigation are passed in, so + * Pure: the URL builder and the navigation are passed in, so * the module runs under vitest's node environment with nothing mocked. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page @@ -23,62 +22,6 @@ export const INTEGRIQ_CONNECTIONS_PATH = '/apps/integriq/connections?app=keepiq&link=1' -/** - * The English label for each of the six registry statuses (design D3). - * - * `limited` came with hydra#673: the connection works in part. - */ -export const CONNECTION_STATUS_LABELS = Object.freeze({ - configured: 'Configured', - limited: 'Limited', - unconfigured: 'Not configured', - simulated: 'Simulated', - unavailable: 'Not available', - error: 'Error', -}) - -/** - * Build the two connection formatters around a translator. - * - * @param {function(string): string} translate Translates an English source string for this app. - * @return {{connectionStatus: function(unknown): string, connectionSettingsLabel: function(unknown): string}} The formatters, keyed by their manifest names. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page - */ -export function createConnectionFormatters(translate) { - return { - /** - * The label for a status. An unknown value renders itself, because a - * status the app cannot name is still a status the admin should see. - * - * @param {unknown} value The row's `status`. - * @return {string} The label, the raw value, or '' when missing. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page - */ - connectionStatus(value) { - const source = - typeof value === 'string' - && Object.hasOwn(CONNECTION_STATUS_LABELS, value) - ? CONNECTION_STATUS_LABELS[value] - : null - return source ? translate(source) : String(value ?? '') - }, - - /** - * The Open settings link text, or '' when the row has nowhere to send a - * reader. An empty text makes the link cell fall through to plain text. - * - * @param {unknown} value The row's `settingsUrl`. - * @return {string} The link text, or ''. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page - */ - connectionSettingsLabel(value) { - return typeof value === 'string' && value.length > 0 - ? translate('Open settings') - : '' - }, - } -} - /** * Build the Add integration header-action handler. * diff --git a/tests/Unit/Service/Connection/ConnectionObservationsTest.php b/tests/Unit/Service/Connection/ConnectionObservationsTest.php index f0d81cb86..c98117efb 100644 --- a/tests/Unit/Service/Connection/ConnectionObservationsTest.php +++ b/tests/Unit/Service/Connection/ConnectionObservationsTest.php @@ -77,15 +77,19 @@ public function testARangeLookupMapsByStatusCode(): void { }//end testARangeLookupMapsByStatusCode() /** - * A sink change reports only when no sink is left on. + * A sink change reports only when no sink is left on: disabled when sinks are off, unconfigured when none exists. * * @return void */ public function testASinkChangeReportsOnlyWhenNoSinkIsOn(): void { - $this->assertNull(actual: $this->observations->siemSinksChanged(enabledSinks: 2)); + $this->assertNull(actual: $this->observations->siemSinksChanged(enabledSinks: 2, sinks: 3)); $this->assertSame( - expected: ['unconfigured', 'No SIEM sink is switched on. Add one under SIEM audit export.'], - actual: $this->observations->siemSinksChanged(enabledSinks: 0) + expected: ['disabled', 'Every SIEM sink is switched off, so no audit event is forwarded.'], + actual: $this->observations->siemSinksChanged(enabledSinks: 0, sinks: 2) + ); + $this->assertSame( + expected: ['unconfigured', 'No SIEM sink is added yet. Add one under SIEM audit export.'], + actual: $this->observations->siemSinksChanged(enabledSinks: 0, sinks: 0) ); }//end testASinkChangeReportsOnlyWhenNoSinkIsOn() @@ -99,29 +103,33 @@ public function testADrainMapsItsSinks(): void { $failed = ['host' => 'logs.gemeente.example', 'ok' => false]; $this->assertSame( - expected: ['unconfigured', 'No SIEM sink is switched on. Add one under SIEM audit export.'], - actual: $this->observations->siemDrain(enabledSinks: 0, delivered: []) + expected: ['disabled', 'Every SIEM sink is switched off, so no audit event is forwarded.'], + actual: $this->observations->siemDrain(enabledSinks: 0, delivered: [], sinks: 1) + ); + $this->assertSame( + expected: ['unconfigured', 'No SIEM sink is added yet. Add one under SIEM audit export.'], + actual: $this->observations->siemDrain(enabledSinks: 0, delivered: [], sinks: 0) ); - $this->assertNull(actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [])); + $this->assertNull(actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [], sinks: 2)); $this->assertSame( expected: ['configured', 'The SIEM sink at siem.gemeente.example took the last delivery.'], - actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [$ok]) + actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [$ok], sinks: 1) ); $this->assertSame( expected: ['configured', 'All 2 SIEM sinks took their last delivery.'], - actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$ok, $ok]) + actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$ok, $ok], sinks: 2) ); $this->assertSame( expected: ['error', 'The last delivery to the SIEM sink at logs.gemeente.example failed.'], - actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [$failed]) + actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [$failed], sinks: 1) ); $this->assertSame( expected: ['limited', '1 of 2 SIEM sinks took their last delivery. The first to fail is at logs.gemeente.example.'], - actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$ok, $failed]) + actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$ok, $failed], sinks: 2) ); $this->assertSame( expected: ['error', 'None of the 2 SIEM sinks took their last delivery. The first to fail is at logs.gemeente.example.'], - actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$failed, $failed]) + actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [$failed, $failed], sinks: 2) ); }//end testADrainMapsItsSinks() @@ -133,11 +141,11 @@ public function testADrainMapsItsSinks(): void { public function testASinkWithoutAHostIsLeftOut(): void { $this->assertSame( expected: ['configured', 'The SIEM sink took the last delivery.'], - actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [['host' => '', 'ok' => true]]) + actual: $this->observations->siemDrain(enabledSinks: 1, delivered: [['host' => '', 'ok' => true]], sinks: 1) ); $this->assertSame( expected: ['error', 'None of the 2 SIEM sinks took their last delivery.'], - actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [['host' => '', 'ok' => false], ['host' => '', 'ok' => false]]) + actual: $this->observations->siemDrain(enabledSinks: 2, delivered: [['host' => '', 'ok' => false], ['host' => '', 'ok' => false]], sinks: 2) ); }//end testASinkWithoutAHostIsLeftOut() diff --git a/tests/Unit/Service/Connection/ConnectionReporterTest.php b/tests/Unit/Service/Connection/ConnectionReporterTest.php index 6f5f3d899..01f7c6ef4 100644 --- a/tests/Unit/Service/Connection/ConnectionReporterTest.php +++ b/tests/Unit/Service/Connection/ConnectionReporterTest.php @@ -177,6 +177,17 @@ protected function resolveEventClass(string $eventClass): ?string { }; }//end reporterWithoutIntegriq() + /** + * A sink count the reporter must not take, because a sink is enabled. + * + * @return callable(): int + */ + private function sinkCountNeverTaken(): callable { + return function (): int { + $this->fail(message: 'All sinks were counted while a sink is enabled.'); + }; + }//end sinkCountNeverTaken() + /** * A sink as the drain leaves it. * @@ -253,7 +264,8 @@ public function testADrainReportsUnderTheSiemKey(): void { attemptedSinks: [ $this->sink(endpoint: 'https://siem.gemeente.example/in', status: 'ok'), $this->sink(endpoint: 'logs.gemeente.example:6514', status: 'failing'), - ] + ], + sinkCount: $this->sinkCountNeverTaken() ) ); @@ -270,7 +282,7 @@ public function testADrainReportsUnderTheSiemKey(): void { * @return void */ public function testADrainThatMetNothingSendsNothing(): void { - $this->assertFalse(condition: $this->reporter()->reportSiemDrain(enabledSinks: 3, attemptedSinks: [])); + $this->assertFalse(condition: $this->reporter()->reportSiemDrain(enabledSinks: 3, attemptedSinks: [], sinkCount: $this->sinkCountNeverTaken())); $this->assertSame(expected: [], actual: $this->sent); $this->assertArrayNotHasKey(key: 'connection_report_siem', array: $this->store); }//end testADrainThatMetNothingSendsNothing() @@ -284,7 +296,9 @@ public function testADrainThatMetNothingSendsNothing(): void { * @return void */ public function testASinkChangeRefreshesBeforeItReports(): void { - $this->assertTrue(condition: $this->reporter()->siemSinksChanged(enabledSinkCount: static fn (): int => 0)); + $this->assertTrue( + condition: $this->reporter()->siemSinksChanged(enabledSinkCount: static fn (): int => 0, sinkCount: static fn (): int => 0) + ); $this->assertSame( expected: [['ConnectionRefreshRequestedEvent', 'siem', ''], ['ConnectionStatusReportedEvent', 'siem', 'unconfigured']], @@ -293,13 +307,42 @@ public function testASinkChangeRefreshesBeforeItReports(): void { $this->assertSame(expected: 'keepiq', actual: $this->sent[0]->app); }//end testASinkChangeRefreshesBeforeItReports() + /** + * Switching off the last enabled sink reports disabled, with no host, from a save and from a drain. + * + * The sinks still exist, so an admin chose to stop the export (hydra + * connection-registry D4, D12 item 9). Not configured would read as a step + * nobody took. + * + * @return void + */ + public function testSwitchingEverySinkOffReportsDisabled(): void { + $reporter = $this->reporter(); + + $this->assertTrue( + condition: $reporter->siemSinksChanged(enabledSinkCount: static fn (): int => 0, sinkCount: static fn (): int => 2) + ); + $this->assertSame( + expected: [['ConnectionRefreshRequestedEvent', 'siem', ''], ['ConnectionStatusReportedEvent', 'siem', 'disabled']], + actual: $this->sentSummary() + ); + $this->assertSame(expected: 'Every SIEM sink is switched off, so no audit event is forwarded.', actual: $this->sent[1]->message); + + $this->store = []; + $this->sent = []; + $this->assertTrue(condition: $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [], sinkCount: static fn (): int => 1)); + $this->assertSame(expected: [['ConnectionStatusReportedEvent', 'siem', 'disabled']], actual: $this->sentSummary()); + }//end testSwitchingEverySinkOffReportsDisabled() + /** * A sink change with sinks still on sends the refresh alone. * * @return void */ public function testASinkChangeWithSinksOnSendsTheRefreshAlone(): void { - $this->assertFalse(condition: $this->reporter()->siemSinksChanged(enabledSinkCount: static fn (): int => 1)); + $this->assertFalse( + condition: $this->reporter()->siemSinksChanged(enabledSinkCount: static fn (): int => 1, sinkCount: $this->sinkCountNeverTaken()) + ); $this->assertSame(expected: [['ConnectionRefreshRequestedEvent', 'siem', '']], actual: $this->sentSummary()); }//end testASinkChangeWithSinksOnSendsTheRefreshAlone() @@ -362,7 +405,7 @@ public function testEachConnectionKeepsItsOwnMemory(): void { $reporter = $this->reporter(); $this->assertTrue(condition: $reporter->reportBreachLookup(httpStatus: 200)); - $this->assertTrue(condition: $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [])); + $this->assertTrue(condition: $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [], sinkCount: static fn (): int => 0)); $this->assertCount(expectedCount: 2, haystack: $this->sent); }//end testEachConnectionKeepsItsOwnMemory() @@ -375,7 +418,7 @@ public function testEachConnectionKeepsItsOwnMemory(): void { public function testASaveClearsItsOwnMemory(): void { $reporter = $this->reporter(); $reporter->reportBreachLookup(httpStatus: 200); - $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: []); + $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [], sinkCount: static fn (): int => 0); $reporter->breachCheckSaved(); @@ -405,7 +448,8 @@ public function testNoMessageCarriesTheInputOrAToken(): void { attemptedSinks: [ $this->sink(endpoint: 'https://svc:' . $token . '@hooks.gemeente.example/ingest?token=' . $token, status: 'failing'), $this->sink(endpoint: 'https://hooks.gemeente.example/' . $token, status: 'ok'), - ] + ], + sinkCount: $this->sinkCountNeverTaken() ); $this->assertCount(expectedCount: 6, haystack: $this->sent); @@ -440,10 +484,14 @@ public function testWithoutIntegriqNothingIsReadSentStoredOrLogged(): void { enabledSinkCount: static function () use (&$counted): int { $counted = true; return 0; + }, + sinkCount: static function () use (&$counted): int { + $counted = true; + return 0; } ) ); - $this->assertFalse(condition: $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [])); + $this->assertFalse(condition: $reporter->reportSiemDrain(enabledSinks: 0, attemptedSinks: [], sinkCount: static fn (): int => 0)); $this->assertFalse(condition: $counted); }//end testWithoutIntegriqNothingIsReadSentStoredOrLogged() diff --git a/tests/Unit/Service/SiemConnectionReportCallersTest.php b/tests/Unit/Service/SiemConnectionReportCallersTest.php index 11e2ca162..cebf34f05 100644 --- a/tests/Unit/Service/SiemConnectionReportCallersTest.php +++ b/tests/Unit/Service/SiemConnectionReportCallersTest.php @@ -257,6 +257,26 @@ public function testDeletingTheLastSinkRefreshesThenReports(): void { ); }//end testDeletingTheLastSinkRefreshesThenReports() + /** + * Switching off the last enabled sink refreshes, then reports disabled from the count of every sink. + * + * @return void + */ + public function testSwitchingOffTheLastSinkReportsDisabled(): void { + $sink = $this->sink(id: 'sink-1', endpoint: 'https://siem.gemeente.example/in'); + $this->sinkMapper->method('findById')->willReturn($sink); + $this->sinkMapper->method('findEnabled')->willReturn([]); + $this->sinkMapper->expects($this->once())->method('findAll')->willReturn([$sink]); + + $this->sinkService(reporter: $this->recordingReporter())->updateSink(adminUid: 'admin', sinkId: 'sink-1', params: ['enabled' => false]); + + $this->assertSame( + expected: [['ConnectionRefreshRequestedEvent', 'siem', ''], ['ConnectionStatusReportedEvent', 'siem', 'disabled']], + actual: $this->sentSummary() + ); + $this->assertStringNotContainsString(needle: 'siem.gemeente.example', haystack: $this->sent[1]->message); + }//end testSwitchingOffTheLastSinkReportsDisabled() + /** * Creating and changing a sink each ask for a refresh. * @@ -319,7 +339,8 @@ function (string $sinkId): array { 2, $this->callback( callback: static fn (array $sinks): bool => $sinks === [$tried] && $tried->getLastDeliveryStatus() === 'failing' - ) + ), + $this->callback(callback: static fn (mixed $sinkCount): bool => is_callable($sinkCount)) )->willReturn(true); $this->siemService(reporter: $reporter)->deliverDue(); diff --git a/tests/Unit/Settings/ConnectionsDeclarationTest.php b/tests/Unit/Settings/ConnectionsDeclarationTest.php index f667b0c49..eadb5340d 100644 --- a/tests/Unit/Settings/ConnectionsDeclarationTest.php +++ b/tests/Unit/Settings/ConnectionsDeclarationTest.php @@ -41,10 +41,11 @@ class ConnectionsDeclarationTest extends TestCase { /** * Integriq's schema, fetched with `gh api` from integriq `development` on - * 2026-09-14, where the file was last changed in - * a93665880f7f552d8280b84a1f5ce402507c4466. It carries amendments 1 to 7 - * (`reportedOnly`, `adapter.jsonPath`, `adapter.simulatedValues` and the - * `{configKey, jsonPath}` form of `requiredConfig`). + * 2026-09-15, where the file was last changed in + * 64b437fc2df24827985ce6e919fe5e47c5205617 (integriq#2024). It carries + * amendments 1 to 9 (`reportedOnly`, `adapter.jsonPath`, + * `adapter.simulatedValues`, the `{configKey, jsonPath}` form of + * `requiredConfig`, and `switch` with `disabledMessage`). * * @var string */ @@ -266,19 +267,25 @@ public function testEverySettingsLinkPointsAtARenderedSection(): void { }//end testEverySettingsLinkPointsAtARenderedSection() /** - * The breach check requires the one switch that gates every lookup. + * The breach check declares the one switch that gates every lookup, and requires nothing. * - * The switch is written as a boolean. Integriq reads a stored `false` as - * empty (hydra#676), so a switched-off check reads Not configured. + * The switch is written as a boolean. Integriq reads a stored `false`, an + * unset key and `0` as empty, and a switch without `offValues` is off when + * empty, so a switched-off check reads `disabled` (hydra connection-registry + * D4 rule 2b, D12 item 9). The key stays out of `requiredConfig`: a filled + * switch says the check may run, not that Have I Been Pwned answered. * * @return void */ - public function testTheBreachCheckRequiresItsSwitch(): void { + public function testTheBreachCheckDeclaresItsSwitch(): void { $hibp = $this->connectionsByKey()['hibp']; - $this->assertSame(expected: ['breach_check_enabled'], actual: $hibp['requiredConfig']); + $this->assertSame(expected: ['configKey' => 'breach_check_enabled'], actual: $hibp['switch']); + $this->assertArrayNotHasKey(key: 'requiredConfig', array: $hibp); $this->assertArrayNotHasKey(key: 'reportedOnly', array: $hibp); $this->assertArrayNotHasKey(key: 'adapter', array: $hibp); + $this->assertStringContainsString(needle: 'switched off', haystack: $hibp['disabledMessage']); + $this->assertStringStartsWith(prefix: 'Not checked yet.', string: $hibp['unconfiguredMessage']); $this->assertStringContainsString( needle: "getValueBool(Application::APP_ID, 'breach_check_enabled', false) === false", @@ -288,7 +295,7 @@ public function testTheBreachCheckRequiresItsSwitch(): void { needle: "setValueBool(\$appId, 'breach_check_enabled',", haystack: $this->read(path: 'lib/Service/AdminSettingsService.php') ); - }//end testTheBreachCheckRequiresItsSwitch() + }//end testTheBreachCheckDeclaresItsSwitch() /** * SIEM is one reported-only row for every sink, and carries nothing for integriq to guess from. @@ -301,6 +308,7 @@ public function testSiemIsOneReportedOnlyRow(): void { $this->assertTrue(condition: $siem['reportedOnly']); $this->assertArrayNotHasKey(key: 'requiredConfig', array: $siem); $this->assertArrayNotHasKey(key: 'adapter', array: $siem); + $this->assertArrayNotHasKey(key: 'switch', array: $siem, message: 'sinks are records, so keepiq reports disabled itself'); $this->assertStringStartsWith(prefix: 'Not checked yet.', string: $siem['unconfiguredMessage']); }//end testSiemIsOneReportedOnlyRow() }//end class diff --git a/tests/e2e/workflows/integrations-page.spec.ts b/tests/e2e/workflows/integrations-page.spec.ts index 98e50d7f3..b1e29b4a9 100644 --- a/tests/e2e/workflows/integrations-page.spec.ts +++ b/tests/e2e/workflows/integrations-page.spec.ts @@ -23,8 +23,8 @@ * * WHAT A RED HERE USUALLY MEANS. An empty list in the first test means * integriq has not synced the declaration, or refused it whole. A Breach check - * row stuck on Configured after the switch goes off means the refresh did not - * reach integriq, so an older observation still counts. + * row that does not read disabled after the switch goes off means integriq + * did not sync the declared switch, or runs without hydra#677. * * Locale: nothing forces the E2E language, so statuses are read from the API * and rows are found by their declared titles, which are not translated. @@ -34,7 +34,7 @@ * * @e2e openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#the-page-lists-only-the-rows-of-keepiq * @e2e openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#add-integration-goes-to-integriq - * @e2e openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#a-switched-off-breach-check-reads-not-configured + * @e2e openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#a-switched-off-breach-check-reads-switched-off */ import type { APIRequestContext, Page } from '@playwright/test' @@ -138,7 +138,7 @@ test.describe('Integrations over the connection registry', () => { } }) - test('reads Not configured while the breach check is off, and Configured once it is on', async ({ + test('reads disabled while the breach check is off, and Not checked yet once it is on', async ({ page, }) => { await page.goto(`${APP_BASE}/`, { timeout: 60_000 }) @@ -168,20 +168,21 @@ test.describe('Integrations over the connection registry', () => { } try { - // The save sends ConnectionRefreshRequestedEvent, which retires any - // older lookup report, and integriq's rule 6 reads `false` as empty. + // The save sends ConnectionRefreshRequestedEvent, and integriq's + // rule 2b reads the `false` switch as off. await saveBreachCheck(page, false) await expect .poll(hibpRow, { timeout: 15_000 }) .toBe( - 'unconfigured|Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings.', + 'disabled|Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings.', ) - // Rule 5: the required switch is filled. + // Rule 6: the switch is on, and the refresh retired every older + // lookup report, so nothing has been checked yet. await saveBreachCheck(page, true) await expect .poll(hibpRow, { timeout: 15_000 }) - .toBe('configured|Required settings are filled.') + .toBe('unconfigured|Not checked yet. Keepiq reports here after the next password check reaches Have I Been Pwned.') } finally { // Put the VALUE back. The restore is a save too, so it refreshes the row again. await saveBreachCheck(page, previous) diff --git a/tests/fixtures/Integriq/connections.schema.json b/tests/fixtures/Integriq/connections.schema.json index be80192ee..5c5806dd6 100644 --- a/tests/fixtures/Integriq/connections.schema.json +++ b/tests/fixtures/Integriq/connections.schema.json @@ -55,7 +55,7 @@ }, "requiredConfig": { "type": "array", - "description": "Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null or a missing path reads as not filled.", + "description": "Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null, an empty JSON list or object, or a missing path reads as not filled.", "items": { "oneOf": [ { @@ -120,6 +120,35 @@ "type": "string", "description": "Why the connection is not usable." }, + "switch": { + "type": "object", + "additionalProperties": false, + "required": ["configKey"], + "description": "The setting an admin uses to turn the connection off. Without offValues the connection is off when the value is empty. With offValues it is off only when the value is one of them.", + "properties": { + "configKey": { + "type": "string", + "minLength": 1, + "description": "App-config key that holds the switch." + }, + "jsonPath": { + "type": "string", + "pattern": "^[^.]+(\\.[^.]+)*$", + "description": "Dot path to the switch when configKey holds a JSON object, such as breach.enabled. A missing path reads as empty." + }, + "offValues": { + "type": "array", + "description": "The values that mean the connection is off. Compared case-insensitively after trimming. An unset key is off only when an empty string is listed.", + "items": { + "type": "string" + } + } + } + }, + "disabledMessage": { + "type": "string", + "description": "Shown while the connection is switched off. Default: Switched off in the app's settings." + }, "unconfiguredMessage": { "type": "string", "description": "Shown while nothing is known about the connection yet. Default: Not checked yet." diff --git a/tests/vitest/connectionRegistry.spec.js b/tests/vitest/connectionRegistry.spec.js index 136f75ff7..908b9e24d 100644 --- a/tests/vitest/connectionRegistry.spec.js +++ b/tests/vitest/connectionRegistry.spec.js @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -9,16 +10,23 @@ * one icon by NAME. A misspelled name renders a raw enum, no glyph, or an Add * integration that does nothing, and none of them logs a thing. So this spec * reads the real fragment and checks every name against what has to answer it. + * The two formatters are @conduction/nextcloud-vue built-ins since 3.2.0, so + * their names are checked against the installed library, not a local copy. + * + * The library's map is IMPORTED and called, not read as text. A regex over the + * module source answers about the file on disk, which is one step beside the + * question: whether the formatter the page resolves actually returns the label. + * The import reaches @nextcloud/auth through formatMetric, which wants a + * `window`, so this file runs on jsdom rather than the suite's default node. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-004-an-admin-reads-the-connections-on-an-integrations-page */ +import { BUILT_IN_FORMATTERS } from '@conduction/nextcloud-vue/src/utils/builtInFormatters.js' import * as fs from 'fs' import * as path from 'path' import { describe, expect, it } from 'vitest' import { - CONNECTION_STATUS_LABELS, - createConnectionFormatters, createConnectionHandlers, INTEGRIQ_CONNECTIONS_PATH, } from '../../src/services/connectionRegistry.js' @@ -29,62 +37,23 @@ const fragment = JSON.parse(read('src', 'manifest.d', '80-connection-registry.js const page = fragment.pages.find((p) => p.id === 'Integrations') const menu = fragment.menu.find((m) => m.id === 'IntegrationsMenu') -/** A translator that marks what it translated, so a missing call shows. */ -const translate = (source) => `t:${source}` - -describe('connection formatters', () => { - const formatters = createConnectionFormatters(translate) - - it('labels all six statuses, limited included', () => { - expect(Object.keys(CONNECTION_STATUS_LABELS).sort()).toEqual([ - 'configured', - 'error', - 'limited', - 'simulated', - 'unavailable', - 'unconfigured', - ]) - expect(formatters.connectionStatus('configured')).toBe('t:Configured') - expect(formatters.connectionStatus('limited')).toBe('t:Limited') - expect(formatters.connectionStatus('unconfigured')).toBe('t:Not configured') - expect(formatters.connectionStatus('simulated')).toBe('t:Simulated') - expect(formatters.connectionStatus('unavailable')).toBe('t:Not available') - expect(formatters.connectionStatus('error')).toBe('t:Error') - }) - - // A connection that works in part is neither working nor broken, so it must - // not borrow either label. - it('keeps limited apart from configured, not available and error', () => { - const limited = formatters.connectionStatus('limited') - expect(limited).not.toBe(formatters.connectionStatus('configured')) - expect(limited).not.toBe(formatters.connectionStatus('unavailable')) - expect(limited).not.toBe(formatters.connectionStatus('error')) - }) - - it('renders an unknown status as itself and a missing one as empty', () => { - expect(formatters.connectionStatus('degraded')).toBe('degraded') - expect(formatters.connectionStatus('toString')).toBe('toString') - expect(formatters.connectionStatus(null)).toBe('') - expect(formatters.connectionStatus(undefined)).toBe('') - }) - - it('offers Open settings only when the row has a settings link', () => { - expect( - formatters.connectionSettingsLabel( - '/settings/admin/keepiq#section-siem', - ), - ).toBe('t:Open settings') - expect(formatters.connectionSettingsLabel('')).toBe('') - expect(formatters.connectionSettingsLabel(undefined)).toBe('') - expect(formatters.connectionSettingsLabel(null)).toBe('') - }) +/** + * The formatter registry CnAppRoot provides, built the way CnAppRoot builds it: + * the library's built-ins under whatever the app passes in its `formatters` + * prop. A same-named local formatter wins, which is why keepiq passes none. + * + * @param {object} appFormatters What the app hands CnAppRoot. Empty by default. + * @return {object} The merged registry, keyed by formatter name. + */ +function shellFormatterRegistry(appFormatters = {}) { + return { ...BUILT_IN_FORMATTERS, ...appFormatters } +} - it('ships an English and a Dutch catalogue entry for every label the page shows', () => { +describe('connection strings', () => { + it('ships an English and a Dutch catalogue entry for every label the page declares', () => { const en = JSON.parse(read('l10n', 'en.json')).translations const nl = JSON.parse(read('l10n', 'nl.json')).translations const labels = [ - ...Object.values(CONNECTION_STATUS_LABELS), - 'Open settings', page.title, menu.label, page.config.folderSidebar.allLabel, @@ -95,9 +64,6 @@ describe('connection formatters', () => { expect(en[label], `en: ${label}`).toBe(label) expect(nl[label], `nl: ${label}`).toBeTruthy() } - expect(nl.Limited).toBe('Beperkt') - // The browser reads the .js catalogue, never the .json one. - expect(read('l10n', 'nl.js')).toContain('"Limited": "Beperkt"') }) }) @@ -146,31 +112,54 @@ describe('the Integrations page declaration', () => { expect(menu.visibleIf).toEqual({ appInstalled: 'integriq' }) }) - it('names only formatters and handlers that exist, and wires both into the shell', () => { - const formatters = createConnectionFormatters(translate) + it('names only formatters the library ships and handlers that exist, and wires the handler into the shell', () => { + const registry = shellFormatterRegistry() const handlers = createConnectionHandlers({ generateUrl: (p) => p, assign: () => {}, }) - for (const column of page.config.columns.filter((c) => c.formatter)) { - expect(typeof formatters[column.formatter], column.formatter).toBe( - 'function', - ) + expect(typeof registry.date, 'the built-in formatter map was read').toBe('function') + const named = page.config.columns.filter((c) => c.formatter).map((c) => c.formatter) + expect(named.sort()).toEqual(['connectionSettingsLabel', 'connectionStatus']) + for (const formatter of named) { + expect(typeof registry[formatter], `@conduction/nextcloud-vue ships ${formatter}`).toBe('function') } for (const action of page.config.headerActions) { expect(typeof handlers[action.handler], action.handler).toBe('function') } const app = read('src', 'App.vue') - expect(app).toContain(':formatters="formatters"') expect(app).toContain(':customComponents="shellCustomComponents"') - expect(app).toContain('formatters: createConnectionFormatters(') expect(app).toMatch( /shellCustomComponents\(\) \{\s+return \{\s+\.\.\.this\.customComponents,\s+\.\.\.createConnectionHandlers\(\{/, ) }) + // The library labels all seven statuses. A copy of the formatter passed to + // CnAppRoot would win over the built-in and could predate `disabled`, which + // is the status the hibp switch introduces. + it('lets the library label the statuses, disabled included', () => { + const registry = shellFormatterRegistry() + + expect(registry.connectionStatus('disabled')).toBe('Switched off') + expect(registry.connectionStatus('unconfigured')).toBe('Not configured') + expect(registry.connectionStatus('configured')).toBe('Configured') + }) + + // THE SHADOW. CnAppRoot merges `{ ...BUILT_IN_FORMATTERS, ...formatters }`, + // so a local formatter under either name silently replaces the built-in and + // nothing logs. keepiq passes no formatters at all, and this states what + // that buys: the built-in is what the Status column resolves. + it('passes CnAppRoot no formatters, so nothing shadows the built-ins', () => { + const shadow = shellFormatterRegistry({ + connectionStatus: () => 'a local copy answered', + }) + + expect(shadow.connectionStatus('disabled')).toBe('a local copy answered') + expect(read('src', 'App.vue')).not.toContain(':formatters=') + }) + it('names an icon src/icons.js registers', () => { const icons = read('src', 'icons.js') for (const icon of [