diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37adaa6..24ec702 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,6 @@ jobs: - run: pnpm screenshot - uses: actions/upload-artifact@v7 with: - name: emdash-openanalytics-screenshots + name: emdash-plugin-openanalytics-screenshots path: docs/screenshots/*.png if-no-files-found: error diff --git a/README.md b/README.md index b97eabc..1b3e2cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @blackswampai/emdash-openanalytics +# @blackswampai/emdash-plugin-openanalytics Native EmDash CMS integration for OpenAnalytics, by Black Swamp AI. @@ -7,20 +7,30 @@ overview inside EmDash. The admin page uses EmDash Block Kit controls, metric cards, notices, a timeseries chart, Top Pages, and Traffic Sources. Private credentials stay on the server. -## Installation +## Quick start Requires EmDash 1.0.1 or later in the 1.x series and Node.js 22.16 or later. -This package has not been published to npm. For local testing, run `pnpm install` -and `pnpm build` in this checkout, then install it from your EmDash site: -```sh -pnpm add /path/to/emdash-openanalytics -``` +1. Run `pnpm add @blackswampai/emdash-plugin-openanalytics` in your EmDash site. +2. Register `openAnalytics()` in the EmDash `plugins` array in `astro.config.mjs`. +3. Ensure your public layout renders `` inside ``. +4. Deploy the site and set `EMDASH_ENCRYPTION_KEY` on its server. +5. Create an OpenAnalytics private read key and save it in this plugin's settings. +6. Open **OpenAnalytics** in plugin navigation; it validates once automatically, + then loads the overview. Revisit or choose a date range to see reports. + +## Install -After an npm release is published, the installation command will be: +Install the package after the first public npm release is available: + +> **No OpenAnalytics credential is needed to install or register this plugin.** +> **Do not put your `oa_sk_...` private read key in the npm install command, +> `astro.config.mjs`, or source code.** Add it after installation through +> OpenAnalytics plugin settings in EmDash. EmDash encrypts secret settings using +> `EMDASH_ENCRYPTION_KEY`. Keep it out of browser code and public environment variables. ```sh -pnpm add @blackswampai/emdash-openanalytics +pnpm add @blackswampai/emdash-plugin-openanalytics ``` Register the native plugin in `astro.config.mjs`: @@ -28,7 +38,7 @@ Register the native plugin in `astro.config.mjs`: ```js import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; -import { openAnalytics } from "@blackswampai/emdash-openanalytics"; +import { openAnalytics } from "@blackswampai/emdash-plugin-openanalytics"; export default defineConfig({ integrations: [ @@ -48,37 +58,29 @@ a context created with `createPublicPageContext` from `emdash/page`. Import `EmDashHead` from `emdash/ui`. Layouts without that insertion point cannot render the tracker. See [EmDash's page fragment guide](https://docs.emdashcms.com/plugins/creating-native-plugins/page-fragments/). -## OpenAnalytics setup - -1. Create a **private read key** in OpenAnalytics for the site you want to track. - Request `site:read` and `analytics:read`. Connection validation uses `site:read`; - the admin overview uses `analytics:read`. - Older keys may only have `site:read`; site validation cannot verify the extra scope. -2. Configure `EMDASH_ENCRYPTION_KEY` on the EmDash server **before saving a key**. - Follow [EmDash's secrets and key management guide](https://docs.emdashcms.com/deployment/secrets/). -3. Open **Plugins**, then the settings control for this plugin. Save the API URL, - private read key, tracking switch, and analytics timezone. Set the timezone - to your site's IANA timezone, such as `America/New_York`. It defaults to UTC; - EmDash's native plugin context does not expose the host site's timezone. - Tracking defaults to enabled, but no tracker appears before successful validation. -4. Open **OpenAnalytics** in EmDash's plugin navigation and click **Validate connection**. - The page shows the connected site, tracking readiness, API URL, and last - validation time. Use **Revalidate connection** after rotating tracker settings. - -The existing protected validation route also remains available to administrators: +## Configure -```js -const response = await fetch("/_emdash/api/plugins/emdash-openanalytics/validate-connection", { - method: "POST", - headers: { "X-EmDash-Request": "1" }, -}); -console.log(await response.json()); -``` +1. Set `EMDASH_ENCRYPTION_KEY` on the EmDash server before saving a secret. See + [EmDash's secrets and key management guide](https://docs.emdashcms.com/deployment/secrets/). +2. In OpenAnalytics, create a private read key with `site:read` and `analytics:read`. + The first scope validates the site; the second allows the overview and reports. +3. In EmDash, open **Plugins** and the OpenAnalytics settings. Set the API URL, + private read key, tracking switch, and IANA analytics timezone (for example, + `America/New_York`; the default is UTC). Save settings. +4. Open **OpenAnalytics** in plugin navigation. With no matching saved snapshot, + the page validates once and then loads the overview. It shows the connected + site, tracking readiness, API URL, and last validation time. +5. If automatic validation fails, use **Retry connection**. The same failed + configuration will not trigger another automatic attempt. Changing settings + permits a fresh attempt. For an established connection, **Refresh connection** + validates separately from the date range control. + +## Screenshots + +These captures use real EmDash with synthetic analytics data. -The route requires `plugins:manage`. It reads the stored credential on the server; -you do not pass the key in this request. EmDash wraps the plugin's result in its -standard API response envelope. Connection failures contain safe error details. -Successful validation reports site identity, status, and tracker readiness. +![OpenAnalytics overview in EmDash](https://raw.githubusercontent.com/BlackSwampAI/emdash-plugin-openanalytics/dc749d963a528a0f2658380e764a99503eedf95c/docs/screenshots/openanalytics-overview.png) +![OpenAnalytics pages and traffic sources](https://raw.githubusercontent.com/BlackSwampAI/emdash-plugin-openanalytics/dc749d963a528a0f2658380e764a99503eedf95c/docs/screenshots/openanalytics-reports.png) ## Analytics overview @@ -136,19 +138,25 @@ API URL or credential. ## Security -`oa_sk_…` is private and server-only. EmDash's `secret` setting encrypts it at +`oa_sk_…` is private and server-only. Never put it in `astro.config.mjs`, source +code, browser code, or public environment variables. Enter it only in the +OpenAnalytics plugin settings after configuring the host's `EMDASH_ENCRYPTION_KEY`. +EmDash's `secret` setting encrypts it at rest and presents a write-only admin input; the plugin never puts it in public HTML, validation results, or logs. Keep the EmDash encryption key available and back it up according to the host's secret management practices. -`oa_pk_…` is the public browser tracking key. It belongs in page HTML. The plugin +`oa_pk_…` is the public browser tracking key. The plugin retrieves it +automatically from OpenAnalytics and puts it in page HTML. The plugin uses EmDash's structured external-script fragment, which escapes attributes and deduplicates the tracker by a stable fragment key. The API client rejects redirects and uses a five-second timeout. API response bodies and transport exceptions are not echoed into errors. The administrator controls the API and tracker origins: configure endpoints you trust. HTTP works -for local self-hosted deployments; use HTTPS for production credentials. +for local self-hosted deployments. With a remote HTTP endpoint, the private key +travels without encryption; use HTTPS in production. The admin page warns when +the configured API URL uses remote HTTP. ## Self-hosting @@ -159,7 +167,7 @@ it has no hard-coded hosted tracker or collector URL. If any installation field is `null`, the connection can still validate, but no script is emitted. Configure your OpenAnalytics deployment's `COLLECTOR_BASE_URL` -and a live public tracking key, then validate again. This scaffold does not add +and a live public tracking key, then validate again. The plugin does not add manual tracker URL overrides. ## Current limitations @@ -168,8 +176,10 @@ manual tracker URL overrides. devices, sessions browser, individual visitors, custom-event reports, funnels, revenue, web vitals, editor analytics, or realtime polling. OAuth and account or site creation are also outside this plugin's current scope. -- Revalidate after tracker rotation or a collector URL change. Saved installation +- Refresh the connection after tracker rotation or a collector URL change. Saved installation metadata has no automatic expiry; private-key revocation is detected on validation. +- Pre-release installations using the old `emdash-openanalytics` plugin ID must + re-enter their OpenAnalytics settings once after updating. - Static pages receive the snapshot available when they are rendered. Rebuild those pages after changing the connection or tracking switch. - The tracker independently fetches OpenAnalytics's browser configuration. A diff --git a/demo/README.md b/demo/README.md index e2a5ef8..1b79885 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,6 +1,18 @@ # EmDash OpenAnalytics screenshot demo -Run `pnpm screenshot` from the repository root. It builds `dist/`, starts a local OpenAnalytics fixture and an actual EmDash Astro admin using the packaged plugin descriptor, authenticates through EmDash's development-only bypass, configures the fixture connection through the protected settings API, validates it, and captures three screenshots under `docs/screenshots/`. +Run `pnpm screenshot` from the repository root. It builds `dist/`, starts a local +OpenAnalytics fixture and an actual EmDash Astro admin using the packaged plugin +descriptor, authenticates through EmDash's development-only bypass, and opens +the plugin's blank analytics page. The harness fills and saves EmDash's native +settings form with the fixture API URL, synthetic private key, tracking switch, +and timezone. Saving makes no OpenAnalytics request. It then opens the analytics +page again, where the first site validation happens automatically, and captures overview, reports, and +narrow-layout screenshots under `docs/screenshots/`. + +The demo asserts that the blank-settings page has no connection controls, the +first configured page visit makes exactly one site read before the four analytics +reads, and revisiting the page with unchanged settings makes no second site read. +There is no manual validation step in the setup. The fixture and EmDash app bind to `127.0.0.1` on reserved ephemeral ports. Their readiness routes require a per-run random token, and EmDash also refuses to start if its configured port is occupied. The fixture accepts only its synthetic `oa_sk_demo_fixture_only_…` key. The EmDash demo database lives in a newly created temporary directory and is removed on exit. Captures are staged in another temporary directory; before they are copied into the repository, the harness scans rendered DOM text, page HTML, text/JSON browser responses, process output, and PNG bytes for private-key-shaped values and the fixture key. This environment has no OCR tool, so the screenshot check supplements the scan of the exact DOM used to take each image. diff --git a/docs/implementation-footprint.md b/docs/implementation-footprint.md index eac3446..8ddcd5f 100644 --- a/docs/implementation-footprint.md +++ b/docs/implementation-footprint.md @@ -12,6 +12,7 @@ excluded. These are size comparisons, not runtime performance measurements. | EmDash scaffold, PR #1 | 495 | 721 | 9 | 1 | | EmDash native overview, PR #2 | 1458 | 1923 | 10 | 3 | | EmDash pages and sources, PR #3 | 1770 | 2423 | 14 | 5 | +| EmDash 0.1.0 release candidate | 1903 | 2711 | 14 | 5 | | n8n OpenAnalytics 0.1.1 | 607 | 613 | 5 | 11 | PR #2 adds **963 production lines** and **1202 test lines** @@ -26,6 +27,15 @@ The admin coordinator delegates range handling, connection blocks, overview/char rendering, and report tables to four small modules. Successful sections remain visible when another read fails. +The release candidate is `@blackswampai/emdash-plugin-openanalytics`, from +`BlackSwampAI/emdash-plugin-openanalytics`, with native plugin ID `openanalytics`. +Release hardening adds automatic first-load validation, connection controls apart +from the date range, bounded upstream responses and route bodies, package checks, +and a form-driven screenshot setup. It adds no analytics reports or endpoints. +A fresh connection adds one site read before the existing four analytics reads; +matching snapshots skip that site read on revisits and range changes. Failed +configuration fingerprints require an explicit connection retry. + The EmDash plugin now owns a native admin page, public tracker insertion, credential-bound installation snapshots, secure read transport, response validation/projection, and useful connection/error/freshness states. The n8n @@ -76,3 +86,4 @@ size. EmDash PR #3 packs to **about 23.7 kB** (decimal), compared with Its package includes compiled workflow operations, source maps, and icons; the EmDash package includes the compiled plugin, declarations, README, and two contract/footprint documents. Package size reflects those different contents. +The current 0.1.0 release candidate packs to approximately **26.5 kB** (decimal). diff --git a/docs/screenshots/openanalytics-narrow.png b/docs/screenshots/openanalytics-narrow.png index 77fcee4..f8c306a 100644 Binary files a/docs/screenshots/openanalytics-narrow.png and b/docs/screenshots/openanalytics-narrow.png differ diff --git a/docs/screenshots/openanalytics-overview.png b/docs/screenshots/openanalytics-overview.png index 190d250..bcb0949 100644 Binary files a/docs/screenshots/openanalytics-overview.png and b/docs/screenshots/openanalytics-overview.png differ diff --git a/docs/screenshots/openanalytics-reports.png b/docs/screenshots/openanalytics-reports.png index 5adb040..05ce7a7 100644 Binary files a/docs/screenshots/openanalytics-reports.png and b/docs/screenshots/openanalytics-reports.png differ diff --git a/docs/upstream-contracts.md b/docs/upstream-contracts.md index a99e2fd..4a87974 100644 --- a/docs/upstream-contracts.md +++ b/docs/upstream-contracts.md @@ -1,5 +1,64 @@ # Verified upstream contracts +## Release naming and current native-plugin contract (2026-09-29) + +Before the release naming change, I rechecked the current EmDash upstream +`main` ref with `git ls-remote`: `54209bc9bd0b48e12bdefa8ac971da01ced7990f`. +This is the pinned upstream revision for the release-convention check. A direct +source clone was unavailable, so I used GitHub's public API to read the relevant +source files at this exact commit. + +The current [native plugin distribution guide](https://docs.emdashcms.com/plugins/creating-native-plugins/distributing/) +explicitly says `definePlugin()` accepts a simple unscoped ID of lowercase +letters, digits, and hyphens, and recommends that form because an ID occupies +one route path segment. It separately shows a scoped npm entrypoint. Therefore +`@blackswampai/emdash-plugin-openanalytics` as the npm package and `openanalytics` +as the native plugin ID follow the upstream recommendation; the package scope +does not need to be repeated in the plugin ID. Current EmDash packages such as +`@emdash-cms/plugin-audit-log` also use `plugin-` in their package name, while +the official distribution guide documents that package identity and plugin ID +are distinct values. No current EmDash technical constraint argues against the +requested Black Swamp AI package family. At the pinned revision, +[`packages/plugins/audit-log/package.json`](https://github.com/emdash-cms/emdash/blob/54209bc9bd0b48e12bdefa8ac971da01ced7990f/packages/plugins/audit-log/package.json) +is named `@emdash-cms/plugin-audit-log`; the native Color example uses +`id: "color"` in +[`packages/plugins/color/src/index.ts`](https://github.com/emdash-cms/emdash/blob/54209bc9bd0b48e12bdefa8ac971da01ced7990f/packages/plugins/color/src/index.ts). +The route-segment recommendation appears in the +[current native distribution guide](https://docs.emdashcms.com/plugins/creating-native-plugins/distributing/). + +The current [native generated-settings documentation](https://docs.emdashcms.com/plugins/creating-native-plugins/react-admin/) +describes `admin.settingsSchema` as the generated form contract. The [official +hook reference](https://docs.emdashcms.com/reference/hooks/) documents plugin +lifecycle, content, media, and public-page hooks; it does not define a settings +save callback. Likewise, the current [hook guide](https://docs.emdashcms.com/plugins/creating-plugins/hooks/) +says hooks are declared at plugin definition time, with `content:afterSave` +being explicitly a content-save hook. There is no documented native settings +`afterSave`/`onSave` extension point to validate credentials after a generated +settings form save. The pinned handler source, +[`packages/core/src/api/handlers/plugin-settings.ts`](https://github.com/emdash-cms/emdash/blob/54209bc9bd0b48e12bdefa8ac971da01ced7990f/packages/core/src/api/handlers/plugin-settings.ts), +shows `handlePluginSettingsUpdate` validating, encrypting, transactionally +writing, and reading back declared keys; it takes no plugin callback and +dispatches no settings lifecycle event. Settings are namespaced as +`plugin:{pluginId}:settings:{key}` in +[`packages/core/src/plugins/settings.ts`](https://github.com/emdash-cms/emdash/blob/54209bc9bd0b48e12bdefa8ac971da01ced7990f/packages/core/src/plugins/settings.ts). +Connection establishment therefore belongs in the authenticated plugin admin +page flow, rather than relying on undocumented host internals. + +GitHub's current [repository rename documentation](https://docs.github.com/en/repositories/creating-and-managing-repositories/renaming-a-repository) +confirms that ordinary web URLs and `git clone`, `git fetch`, and `git push` +requests to the old repository location redirect after a rename. Project Pages +URLs are the exception; GitHub Actions hosted from a renamed repository also do +not redirect. The documented conditions include having organization-owner or +repository-admin permission and not recreating a repository under the old name. +Thus a normal rename from `BlackSwampAI/emdash-openanalytics` to +`BlackSwampAI/emdash-plugin-openanalytics` preserves ordinary repository and +git traffic, subject to those stated exceptions. + +The repository rename was performed after the release branch was committed and +pushed. Both the old and new GitHub API paths resolve to repository ID +`1396756742` and the canonical name `BlackSwampAI/emdash-plugin-openanalytics`; +the old repository path redirects to the renamed repository. + Inspected on 2026-09-29 before implementation: - EmDash [`54209bc9bd0b48e12bdefa8ac971da01ced7990f`](https://github.com/emdash-cms/emdash/tree/54209bc9bd0b48e12bdefa8ac971da01ced7990f), diff --git a/package.json b/package.json index 4fc37b3..d9196f9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@blackswampai/emdash-openanalytics", + "name": "@blackswampai/emdash-plugin-openanalytics", "version": "0.1.0", "description": "Native EmDash plugin for OpenAnalytics", "keywords": [ @@ -8,15 +8,15 @@ "emdash-plugin", "openanalytics" ], - "homepage": "https://github.com/BlackSwampAI/emdash-openanalytics#readme", + "homepage": "https://github.com/BlackSwampAI/emdash-plugin-openanalytics#readme", "bugs": { - "url": "https://github.com/BlackSwampAI/emdash-openanalytics/issues" + "url": "https://github.com/BlackSwampAI/emdash-plugin-openanalytics/issues" }, "license": "MIT", "author": "Black Swamp AI", "repository": { "type": "git", - "url": "git+https://github.com/BlackSwampAI/emdash-openanalytics.git" + "url": "git+https://github.com/BlackSwampAI/emdash-plugin-openanalytics.git" }, "files": [ "dist", @@ -32,6 +32,9 @@ "import": "./dist/index.mjs" } }, + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsdown src/index.ts --format esm --dts --clean", "dev": "tsdown src/index.ts --format esm --dts --watch", @@ -60,7 +63,7 @@ "react-dom": "19.3.0", "tsdown": "0.20.3", "typescript": "5.9.3", - "vitest": "4.0.15" + "vitest": "4.1.11" }, "peerDependencies": { "emdash": ">=1.0.1 <2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21f633a..0c95663 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: 5.9.3 version: 5.9.3 vitest: - specifier: 4.0.15 - version: 4.0.15(@types/node@24.10.1)(lightningcss@1.33.0) + specifier: 4.1.11 + version: 4.1.11(@types/node@24.10.1)(vite@8.3.1(@types/node@24.10.1)(esbuild@0.28.2)) packages: @@ -931,13 +931,6 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} - cpu: [x64] - os: [linux] - libc: [glibc] - '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1551,144 +1544,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.63.5': - resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.63.5': - resolution: {integrity: sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.63.5': - resolution: {integrity: sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.63.5': - resolution: {integrity: sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.63.5': - resolution: {integrity: sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.63.5': - resolution: {integrity: sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.63.5': - resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.63.5': - resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.63.5': - resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.63.5': - resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.63.5': - resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.63.5': - resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.63.5': - resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.63.5': - resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.63.5': - resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.63.5': - resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.63.5': - resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.63.5': - resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.63.5': - resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.63.5': - resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.63.5': - resolution: {integrity: sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.63.5': - resolution: {integrity: sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.63.5': - resolution: {integrity: sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.63.5': - resolution: {integrity: sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.63.5': - resolution: {integrity: sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==} - cpu: [x64] - os: [win32] - '@shikijs/core@4.4.3': resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} @@ -2102,34 +1957,34 @@ packages: oxc-transform-react: optional: true - '@vitest/expect@4.0.15': - resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.0.15': - resolution: {integrity: sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.15': - resolution: {integrity: sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.0.15': - resolution: {integrity: sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.0.15': - resolution: {integrity: sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.0.15': - resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.0.15': - resolution: {integrity: sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@wordpress/block-serialization-default-parser@5.56.0': resolution: {integrity: sha512-ZS2DpbXSFke+Hqld0l2oB8SxNj49AC6MidE0CKA0MYk5Xd/eBeDUNt/JqjpVJlWcdiO+7kVCe8VP7YPOkON64A==} @@ -2290,6 +2145,9 @@ packages: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} @@ -2500,9 +2358,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -3379,11 +3234,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.63.5: - resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -3502,8 +3352,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3758,46 +3608,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3849,20 +3659,23 @@ packages: vite: optional: true - vitest@4.0.15: - resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.15 - '@vitest/browser-preview': 4.0.15 - '@vitest/browser-webdriverio': 4.0.15 - '@vitest/ui': 4.0.15 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3876,6 +3689,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -4874,9 +4691,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5228,81 +5042,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.63.5': - optional: true - - '@rollup/rollup-android-arm64@4.63.5': - optional: true - - '@rollup/rollup-darwin-arm64@4.63.5': - optional: true - - '@rollup/rollup-darwin-x64@4.63.5': - optional: true - - '@rollup/rollup-freebsd-arm64@4.63.5': - optional: true - - '@rollup/rollup-freebsd-x64@4.63.5': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.63.5': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.63.5': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.63.5': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.63.5': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.63.5': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.63.5': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.63.5': - optional: true - - '@rollup/rollup-linux-x64-musl@4.63.5': - optional: true - - '@rollup/rollup-openbsd-x64@4.63.5': - optional: true - - '@rollup/rollup-openharmony-arm64@4.63.5': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.63.5': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.63.5': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.63.5': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.63.5': - optional: true - '@shikijs/core@4.4.3': dependencies: '@shikijs/primitive': 4.4.3 @@ -5729,43 +5468,45 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.3.1(@types/node@24.10.1)(esbuild@0.28.2) - '@vitest/expect@4.0.15': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.0.15(vite@7.3.6(@types/node@24.10.1)(lightningcss@1.33.0))': + '@vitest/mocker@4.1.11(vite@8.3.1(@types/node@24.10.1)(esbuild@0.28.2))': dependencies: - '@vitest/spy': 4.0.15 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.10.1)(lightningcss@1.33.0) + vite: 8.3.1(@types/node@24.10.1)(esbuild@0.28.2) - '@vitest/pretty-format@4.0.15': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.0.15': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.0.15 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.0.15': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.0.15 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.15': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.0.15': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.0.15 + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 tinyrainbow: 3.1.1 '@wordpress/block-serialization-default-parser@5.56.0': {} @@ -5984,6 +5725,8 @@ snapshots: content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} cookie-es@2.0.1: {} @@ -6232,8 +5975,6 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: @@ -7177,38 +6918,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.11 '@rolldown/binding-win32-x64-msvc': 1.2.11 - rollup@4.63.5: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.5 - '@rollup/rollup-android-arm64': 4.63.5 - '@rollup/rollup-darwin-arm64': 4.63.5 - '@rollup/rollup-darwin-x64': 4.63.5 - '@rollup/rollup-freebsd-arm64': 4.63.5 - '@rollup/rollup-freebsd-x64': 4.63.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.5 - '@rollup/rollup-linux-arm-musleabihf': 4.63.5 - '@rollup/rollup-linux-arm64-gnu': 4.63.5 - '@rollup/rollup-linux-arm64-musl': 4.63.5 - '@rollup/rollup-linux-loong64-gnu': 4.63.5 - '@rollup/rollup-linux-loong64-musl': 4.63.5 - '@rollup/rollup-linux-ppc64-gnu': 4.63.5 - '@rollup/rollup-linux-ppc64-musl': 4.63.5 - '@rollup/rollup-linux-riscv64-gnu': 4.63.5 - '@rollup/rollup-linux-riscv64-musl': 4.63.5 - '@rollup/rollup-linux-s390x-gnu': 4.63.5 - '@rollup/rollup-linux-x64-gnu': 4.63.5 - '@rollup/rollup-linux-x64-musl': 4.63.5 - '@rollup/rollup-openbsd-x64': 4.63.5 - '@rollup/rollup-openharmony-arm64': 4.63.5 - '@rollup/rollup-win32-arm64-msvc': 4.63.5 - '@rollup/rollup-win32-ia32-msvc': 4.63.5 - '@rollup/rollup-win32-x64-gnu': 4.63.5 - '@rollup/rollup-win32-x64-msvc': 4.63.5 - fsevents: 2.3.3 - rope-sequence@1.3.4: {} router@2.2.0: @@ -7387,7 +7096,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.3.0: {} stringify-entities@4.0.4: dependencies: @@ -7581,19 +7290,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.6(@types/node@24.10.1)(lightningcss@1.33.0): - dependencies: - esbuild: 0.28.2 - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - postcss: 8.5.28 - rollup: 4.63.5 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - lightningcss: 1.33.0 - vite@8.3.1(@types/node@24.10.1)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 @@ -7610,42 +7306,32 @@ snapshots: optionalDependencies: vite: 8.3.1(@types/node@24.10.1)(esbuild@0.28.2) - vitest@4.0.15(@types/node@24.10.1)(lightningcss@1.33.0): + vitest@4.1.11(@types/node@24.10.1)(vite@8.3.1(@types/node@24.10.1)(esbuild@0.28.2)): dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.3.6(@types/node@24.10.1)(lightningcss@1.33.0)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@24.10.1)(esbuild@0.28.2)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.2.1 pathe: 2.0.3 picomatch: 4.0.7 - std-env: 3.10.0 + std-env: 4.3.0 tinybench: 2.9.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.6(@types/node@24.10.1)(lightningcss@1.33.0) + vite: 8.3.1(@types/node@24.10.1)(esbuild@0.28.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.1 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-keyname@2.2.8: {} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 48f7b11..c4c8f9d 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -7,6 +7,19 @@ import { join, resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); +assert.equal(pkg.name, "@blackswampai/emdash-plugin-openanalytics"); +assert.equal(pkg.version, "0.1.0"); +assert.equal(pkg.private, undefined); +assert.deepEqual(pkg.publishConfig, { access: "public" }); +assert.equal(pkg.engines.node, ">=22.16"); +assert.equal(pkg.peerDependencies.emdash, ">=1.0.1 <2"); +assert.deepEqual(pkg.files, [ + "dist", + "docs/implementation-footprint.md", + "docs/upstream-contracts.md", +]); +assert.equal(pkg.main, pkg.exports["."].import); +assert.equal(pkg.types, pkg.exports["."].types); await readFile(resolve(root, pkg.exports["."].import)); await readFile(resolve(root, pkg.exports["."].types)); const { openAnalytics, createPlugin } = await import(pkg.name); @@ -17,6 +30,7 @@ const plugin = createPlugin(); assert.equal(descriptor.entrypoint, pkg.name); assert.equal(descriptor.format, "native"); assert.match(descriptor.id, /^[a-z0-9-]+$/); +assert.equal(descriptor.id, "openanalytics"); assert.equal(descriptor.id, plugin.id); assert.equal(descriptor.version, pkg.version); assert.equal(plugin.version, pkg.version); @@ -25,6 +39,8 @@ assert(plugin.capabilities.includes("hooks.page-fragments:register")); assert.equal(typeof plugin.hooks["page:fragments"].handler, "function"); assert.equal(typeof plugin.routes["validate-connection"].handler, "function"); assert.equal(plugin.routes["validate-connection"].permission, "plugins:manage"); +assert.deepEqual(plugin.routes["validate-connection"].methods, ["POST"]); +assert.deepEqual(plugin.routes["validate-connection"].request, { body: "bytes", maxBytes: 4_096 }); assert.equal(typeof plugin.routes.admin.handler, "function"); assert.equal(plugin.routes.admin.permission, "plugins:manage"); assert.deepEqual(plugin.routes.admin.methods, ["POST"]); @@ -49,10 +65,29 @@ try { } finally { rmSync(cache, { recursive: true, force: true }); } -for (const { path } of packed.files) { - assert(!/^(demo|scripts|tests)\//.test(path), `Development fixture packed: ${path}`); - assert(!path.startsWith("docs/screenshots/"), `Screenshot packed: ${path}`); +const allowedFiles = new Set([ + "package.json", + "README.md", + "LICENSE", + "dist/index.mjs", + "dist/index.d.mts", + "docs/implementation-footprint.md", + "docs/upstream-contracts.md", +]); +const packedFiles = packed.files.map(({ path }) => path).sort(); +assert.deepEqual(packedFiles, [...allowedFiles].sort(), "Unexpected npm package file list"); +for (const path of packedFiles) { const contents = await readFile(resolve(root, path), "utf8"); assert(!/oa_sk_[A-Za-z0-9_-]{8,}/.test(contents), `Private credential packed: ${path}`); + assert(!/(?:^|\/)\.env(?:\.|$)/m.test(contents), `Environment file reference packed: ${path}`); + assert(!/\.(?:sqlite|sqlite3|db)(?:\W|$)/i.test(path), `Database artifact packed: ${path}`); + assert( + !/(?:\/home\/|\/tmp\/|\/Users\/|[A-Z]:\\Users\\)/.test(contents), + `Local path packed: ${path}`, + ); + assert( + !/(?:from|import)\s+["'][^"']*(?:\.\/)?tests?\//.test(contents), + `Test import packed: ${path}`, + ); } -console.log("Package exports, native metadata, packed files, and artifact credential scan passed."); +console.log("Package metadata, exports, exact file allowlist, and artifact safety checks passed."); diff --git a/scripts/demo-openanalytics.mjs b/scripts/demo-openanalytics.mjs index 384f745..dde5387 100644 --- a/scripts/demo-openanalytics.mjs +++ b/scripts/demo-openanalytics.mjs @@ -52,7 +52,7 @@ const server = createServer((req, res) => { return send(res, 401, { error: { code: "UNAUTHORIZED", message: "fixture auth required" } }); if (req.method !== "GET") return send(res, 405, { error: { code: "METHOD_NOT_ALLOWED" } }); const path = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`).pathname; - if (path.startsWith("/v1/read/analytics/")) { + if (path === "/v1/read/site" || path.startsWith("/v1/read/analytics/")) { const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); observedReads.push({ path, diff --git a/scripts/screenshot-demo.mjs b/scripts/screenshot-demo.mjs index 757b2d4..1283649 100644 --- a/scripts/screenshot-demo.mjs +++ b/scripts/screenshot-demo.mjs @@ -221,44 +221,48 @@ try { ); }); await getSession(page); - const headers = { "X-EmDash-Request": "1", "Content-Type": "application/json", Origin: BASE }; - const settings = await page.request.put( - `${BASE}/_emdash/api/admin/plugins/emdash-openanalytics/settings`, - { - headers, - data: { - values: { - apiUrl: FIXTURE_BASE, - privateReadKey: SYNTHETIC_KEY, - trackingEnabled: true, - timezone: "America/New_York", - }, - }, - }, + await page.goto(`${BASE}/_emdash/admin/plugins/openanalytics/analytics`); + await page.getByText("Not configured", { exact: true }).waitFor({ state: "visible" }); + if (await page.getByRole("button", { name: /(?:Refresh|Retry) connection/ }).count()) + throw new Error("Blank settings must not offer connection maintenance."); + assertNoSecret(await page.content(), "blank-settings admin HTML"); + await page.goto(`${BASE}/_emdash/admin/plugins-manager/openanalytics/settings`); + await page.getByLabel("OpenAnalytics API URL", { exact: true }).fill(FIXTURE_BASE); + await page.getByLabel("Private read key", { exact: true }).fill(SYNTHETIC_KEY); + await page.getByLabel("Analytics timezone", { exact: true }).fill("America/New_York"); + const trackingSwitch = page.getByRole("switch", { name: "Enable tracking", exact: true }); + if ((await trackingSwitch.getAttribute("aria-checked")) !== "true") await trackingSwitch.click(); + const settingsSaved = page.waitForResponse( + (response) => + response.url() === `${BASE}/_emdash/api/admin/plugins/openanalytics/settings` && + response.request().method() === "PUT", ); - await requireOk(settings, "seed synthetic plugin settings"); - const validation = await page.request.post( - `${BASE}/_emdash/api/plugins/emdash-openanalytics/validate-connection`, - { - headers, - data: {}, - }, - ); - const validationJson = await requireOk(validation, "validate fixture connection"); - const validationResult = validationJson.data ?? validationJson; - if (validationResult.success !== true) - throw new Error("The fixture connection did not validate successfully."); + await page.getByRole("button", { name: "Save Settings", exact: true }).first().click(); + await requireOk(await settingsSaved, "save synthetic plugin settings through the native form"); + await page + .getByText("Settings saved successfully", { exact: true }) + .waitFor({ state: "visible" }); + const beforeNavigation = await (await fetch(`${FIXTURE_BASE}/__requests`)).json(); + if (beforeNavigation.length !== 0) + throw new Error("Saving settings unexpectedly called OpenAnalytics."); const requested = []; page.on("request", (request) => { - if (request.url().includes("/_emdash/api/plugins/emdash-openanalytics")) - requested.push(request.url()); + if (request.url().includes("/_emdash/api/plugins/openanalytics")) requested.push(request.url()); }); - await page.goto(`${BASE}/_emdash/admin/plugins/emdash-openanalytics/analytics`); + await page.goto(`${BASE}/_emdash/admin/plugins/openanalytics/analytics`); await page.locator("text=Top Pages").waitFor({ state: "visible", timeout: 30_000 }); await page.locator("text=Traffic Sources").waitFor({ state: "visible", timeout: 30_000 }); await page.getByText("1284", { exact: true }).waitFor({ state: "visible" }); await page.getByText("EmDash Demo", { exact: true }).waitFor({ state: "visible" }); + await page.getByText("Connected", { exact: true }).waitFor({ state: "visible" }); + const refresh = page.getByRole("button", { name: "Refresh connection", exact: true }); + await refresh.waitFor({ state: "visible" }); + const range = page.getByText("Date range", { exact: true }); + const refreshBounds = await refresh.boundingBox(); + const rangeBounds = await range.boundingBox(); + if (!refreshBounds || !rangeBounds || refreshBounds.y + refreshBounds.height >= rangeBounds.y) + throw new Error("Connection maintenance must appear above the independent date range control."); await page.locator("canvas").first().waitFor({ state: "visible", timeout: 30_000 }); await waitForStableChart(page); await Promise.all(responseScans); @@ -272,6 +276,10 @@ try { for (const line of childLogs) assertNoSecret(line, "demo process logs"); const readLogResponse = await fetch(`${FIXTURE_BASE}/__requests`); const readLog = await readLogResponse.json(); + if (readLog.filter(({ path }) => path === "/v1/read/site").length !== 1) + throw new Error("The first admin load must validate the connection exactly once."); + if (requested.some((url) => url.includes("/validate-connection"))) + throw new Error("The screenshot setup must not explicitly validate the connection."); const analyticsReads = readLog.filter((item) => item.path.startsWith("/v1/read/analytics/")); const relevant = [ "/v1/read/analytics/overview", @@ -289,6 +297,14 @@ try { } if (new Set(analyticsReads.map(({ from, to }) => `${from}|${to}`)).size !== 1) throw new Error("Overview and report calls did not share one selected range."); + // A matching snapshot must survive an ordinary revisit without another site read. + await page.reload(); + await page.getByText("Connected", { exact: true }).waitFor({ state: "visible" }); + await page.getByText("1284", { exact: true }).waitFor({ state: "visible" }); + await waitForStableChart(page); + const revisitReads = await (await fetch(`${FIXTURE_BASE}/__requests`)).json(); + if (revisitReads.filter(({ path }) => path === "/v1/read/site").length !== 1) + throw new Error("An ordinary page revisit revalidated a matching snapshot."); await mkdir(staging, { recursive: true }); await waitForStableChart(page); await page.screenshot({ path: join(staging, "openanalytics-overview.png"), fullPage: false }); diff --git a/src/admin/connection.ts b/src/admin/connection.ts index edc4c82..b983621 100644 --- a/src/admin/connection.ts +++ b/src/admin/connection.ts @@ -34,6 +34,7 @@ export function connectionBlocks(args: { needsValidation?: boolean; notConfigured?: boolean; error?: string; + connectionAction?: { id: "revalidate" | "retry"; label: string; value?: unknown }; }): Block[] { let title = args.notConfigured ? "Not configured" @@ -48,7 +49,7 @@ export function connectionBlocks(args: { let variant: "default" | "alert" | "error" = args.notConfigured || args.needsValidation ? "alert" : "default"; if (args.error) { - title = "Connection needs attention"; + title = args.site ? "Connection needs attention" : "Connection failed"; description = args.error; variant = "error"; } else if (args.site && args.site.status !== "active") { @@ -57,6 +58,15 @@ export function connectionBlocks(args: { variant = "alert"; } const install = args.site?.install; + let insecureRemoteHttp = false; + try { + const parsed = new URL(args.apiUrl); + const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const loopback = host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host); + insecureRemoteHttp = parsed.protocol === "http:" && !loopback; + } catch { + /* Invalid URLs are already represented safely in the API field. */ + } const tracking = !install?.hasTrackingKey ? "No tracking key" : !install.trackerReady @@ -84,13 +94,36 @@ export function connectionBlocks(args: { }, ], }, + ...(args.connectionAction + ? [ + { + type: "actions" as const, + elements: [ + { + type: "button" as const, + action_id: args.connectionAction.id, + label: args.connectionAction.label, + style: "secondary" as const, + ...(args.connectionAction.value === undefined + ? {} + : { value: args.connectionAction.value }), + }, + ], + }, + ] + : []), + ...(insecureRemoteHttp + ? [ + { + type: "context" as const, + text: "HTTP sends your private read key without encryption. Use HTTPS for production.", + }, + ] + : []), ]; } -export function controls( - selected: DateRange, - options: { validate?: boolean; retry?: boolean } = {}, -): Block { +export function controls(selected: DateRange, options: { retry?: boolean } = {}): Block { const labels: Record = { "24h": "Last 24 hours", "7d": "Last 7 days", @@ -117,12 +150,5 @@ export function controls( style: "primary", value: { range: selected }, }); - elements.push({ - type: "button", - action_id: "revalidate", - label: options.validate ? "Validate connection" : "Revalidate connection", - style: "secondary", - value: { range: selected }, - }); return { type: "actions", elements }; } diff --git a/src/admin/page.ts b/src/admin/page.ts index 18e1cfd..1744d24 100644 --- a/src/admin/page.ts +++ b/src/admin/page.ts @@ -23,6 +23,8 @@ type Input = | { type: "page_load"; page: string } | { type: "block_action"; action_id: string; value?: unknown; page?: string }; +const VALIDATION_FAILURE_KEY = "state:admin-validation-failure"; + function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -42,12 +44,18 @@ function parseInput(value: unknown): Input | null { function safeError(kind: unknown, retryAfterSeconds?: number): string { switch (kind) { + case "configuration": + return "The OpenAnalytics API URL or private read key is invalid. Review plugin settings."; case "analytics_forbidden": return "This private key does not have analytics:read permission."; case "suspended": return "This OpenAnalytics site is suspended, so analytics data is unavailable."; case "unauthorized": - return "OpenAnalytics rejected this credential. Revalidate with a current private read key."; + return "OpenAnalytics rejected this credential. Update the private read key in plugin settings, then retry the connection."; + case "not_found": + return "No OpenAnalytics site was found for this private read key. Check the key and site configuration."; + case "invalid_response": + return "OpenAnalytics returned an invalid connection response. Retry the connection shortly."; case "forbidden": return "This private key does not have permission to read OpenAnalytics data."; case "billing": @@ -65,7 +73,7 @@ function safeError(kind: unknown, retryAfterSeconds?: number): string { case "server": return "OpenAnalytics is temporarily unavailable. Try again shortly."; default: - return "OpenAnalytics could not load analytics. Revalidate the connection or try again shortly."; + return "OpenAnalytics could not load analytics. Refresh the connection or try again shortly."; } } @@ -82,6 +90,7 @@ function waitingPage( message: string, needsValidation = true, notConfigured = false, + connectionAction?: { id: "revalidate" | "retry"; label: string }, ): BlockResponse { return { blocks: [ @@ -92,11 +101,12 @@ function waitingPage( needsValidation, notConfigured, error: needsValidation || notConfigured ? undefined : message, + connectionAction: connectionAction + ? { ...connectionAction, value: { range: selected } } + : undefined, }), - controls(selected, { - validate: notConfigured || (needsValidation && message.startsWith("Validate")), - }), - ...(needsValidation ? [{ type: "context" as const, text: message }] : []), + controls(selected), + ...(needsValidation || notConfigured ? [{ type: "context" as const, text: message }] : []), ], }; } @@ -150,9 +160,14 @@ async function loadAnalytics( site, trackingEnabled, validatedAt: snapshot.validatedAt, + connectionAction: { + id: "revalidate", + label: "Refresh connection", + value: { range: selected }, + }, }), - controls(selected, { retry: failures }), { type: "header", text: rangeLabel(selected) }, + controls(selected, { retry: failures }), ...renderOverview({ timezone, overview, @@ -229,17 +244,21 @@ export async function renderAdminPage(ctx: RouteContext): Promise } const { timezone, error: timezoneError } = timezoneFromSetting(timezoneValue); let validationError: string | null = null; + const fingerprint = await configurationFingerprint(config.apiUrl, config.readKey); + let snapshot = await ctx.kv.get(SITE_SNAPSHOT_KEY); + const matchingSnapshot = () => isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint; if (interaction.type === "block_action" && interaction.action_id === "revalidate") { + await ctx.kv.delete(VALIDATION_FAILURE_KEY).catch(() => undefined); const validation = await validateConnection(ctx); if (!validation.success) { validationError = safeError( validation.error.kind, "retryAfterSeconds" in validation.error ? validation.error.retryAfterSeconds : undefined, ); + await ctx.kv.set(VALIDATION_FAILURE_KEY, { fingerprint, message: validationError }); } + snapshot = await ctx.kv.get(SITE_SNAPSHOT_KEY); } - const fingerprint = await configurationFingerprint(config.apiUrl, config.readKey); - const snapshot = await ctx.kv.get(SITE_SNAPSHOT_KEY); if (validationError) { if (isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint) { return { @@ -250,6 +269,11 @@ export async function renderAdminPage(ctx: RouteContext): Promise site: safeConnectionSummary(snapshot.site), trackingEnabled, validatedAt: snapshot.validatedAt, + connectionAction: { + id: "revalidate", + label: "Retry connection", + value: { range: selected }, + }, }), controls(selected), { @@ -261,7 +285,33 @@ export async function renderAdminPage(ctx: RouteContext): Promise ], }; } - return waitingPage(selected, config.apiUrl, trackingEnabled, validationError, false); + return waitingPage(selected, config.apiUrl, trackingEnabled, validationError, false, false, { + id: "revalidate", + label: "Retry connection", + }); + } + const previousFailure = await ctx.kv.get(VALIDATION_FAILURE_KEY); + const failedState = + record(previousFailure) && previousFailure.fingerprint === fingerprint + ? typeof previousFailure.message === "string" + ? previousFailure.message + : "Connection failed. Try again." + : null; + if (!matchingSnapshot() && interaction.type === "page_load" && !failedState) { + const validation = await validateConnection(ctx); + if (validation.success) { + snapshot = await ctx.kv.get(SITE_SNAPSHOT_KEY); + } else { + validationError = safeError( + validation.error.kind, + "retryAfterSeconds" in validation.error ? validation.error.retryAfterSeconds : undefined, + ); + await ctx.kv.set(VALIDATION_FAILURE_KEY, { fingerprint, message: validationError }); + return waitingPage(selected, config.apiUrl, trackingEnabled, validationError, false, false, { + id: "revalidate", + label: "Retry connection", + }); + } } if (timezoneError) { return { @@ -273,6 +323,11 @@ export async function renderAdminPage(ctx: RouteContext): Promise site: safeConnectionSummary(snapshot.site), trackingEnabled, validatedAt: snapshot.validatedAt, + connectionAction: { + id: "revalidate", + label: "Refresh connection", + value: { range: selected }, + }, }) : connectionBlocks({ apiUrl: config.apiUrl, trackingEnabled, needsValidation: true })), controls(selected), @@ -286,13 +341,18 @@ export async function renderAdminPage(ctx: RouteContext): Promise }; } if (!isSiteSnapshot(snapshot) || snapshot.fingerprint !== fingerprint) { + if (failedState) + return waitingPage(selected, config.apiUrl, trackingEnabled, failedState, false, false, { + id: "revalidate", + label: "Retry connection", + }); return waitingPage( selected, config.apiUrl, trackingEnabled, isSiteSnapshot(snapshot) - ? "Configuration changed. Revalidate the connection to resume tracking and load analytics." - : "Validate the connection to load analytics.", + ? "Configuration changed. Open this page to validate the connection and resume tracking." + : "Open this page to validate the connection and load analytics.", ); } return loadAnalytics(config, snapshot, selected, timezone, trackingEnabled); diff --git a/src/index.ts b/src/index.ts index 29774be..3dbc7f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,9 @@ export { createPlugin } from "./plugin"; /** Native EmDash plugin entry descriptor. */ export function openAnalytics(): PluginDescriptor { return { - id: "emdash-openanalytics", + id: "openanalytics", version: "0.1.0", - entrypoint: "@blackswampai/emdash-openanalytics", + entrypoint: "@blackswampai/emdash-plugin-openanalytics", format: "native" as const, }; } diff --git a/src/openanalytics/client.ts b/src/openanalytics/client.ts index 2ea3787..ffd3549 100644 --- a/src/openanalytics/client.ts +++ b/src/openanalytics/client.ts @@ -22,6 +22,7 @@ const SITE_STATUSES = new Set(["active", "suspended", "deleting", "deleted"]); const RESOLUTIONS = new Set(["minute", "hour", "day", "week"]); const FRESHNESS_STATES = new Set(["ok", "no_data", "stale", "degraded"]); const ISO_UTC_INSTANT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/; +const MAX_RESPONSE_BYTES = 1_048_576; export function containsPrivateKey(value: unknown, seen = new Set()): boolean { if (typeof value === "string") { @@ -254,7 +255,7 @@ async function getJSON( let payload: unknown; if (query && (response.status === 400 || response.status === 403)) { try { - payload = await response.json(); + payload = await readJsonResponse(response); } catch { if (controller.signal.aborted) throw new OpenAnalyticsError("timeout"); } @@ -264,7 +265,7 @@ async function getJSON( : errorForStatus(response.status, response.headers.get("retry-after")); } try { - return await response.json(); + return await readJsonResponse(response); } catch { if (controller.signal.aborted) throw new OpenAnalyticsError("timeout"); throw new OpenAnalyticsError("invalid_response"); @@ -278,6 +279,48 @@ async function getJSON( } } +/** Bound upstream payload memory use before parsing untrusted JSON. */ +async function readJsonResponse(response: Response): Promise { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_RESPONSE_BYTES + ) { + await response.body?.cancel(); + throw new OpenAnalyticsError("invalid_response"); + } + if (!response.body) throw new OpenAnalyticsError("invalid_response"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new OpenAnalyticsError("invalid_response"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new OpenAnalyticsError("invalid_response"); + } +} + /** Read the site context associated with a private read key. */ export async function getSite(config: OpenAnalyticsConfig): Promise { const payload = await getJSON(config, "/v1/read/site"); diff --git a/src/openanalytics/errors.ts b/src/openanalytics/errors.ts index 5e95edf..859d5d7 100644 --- a/src/openanalytics/errors.ts +++ b/src/openanalytics/errors.ts @@ -105,7 +105,9 @@ export function errorForStatus( "rate_limited", undefined, status, - seconds !== undefined && Number.isSafeInteger(seconds) ? seconds : undefined, + seconds !== undefined && Number.isSafeInteger(seconds) && seconds <= 86_400 + ? seconds + : undefined, ); } default: diff --git a/src/plugin.ts b/src/plugin.ts index 34e2c31..b20f66b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,7 +7,7 @@ import { trackingFragment } from "./tracking/fragment"; export function createPlugin() { return definePlugin({ - id: "emdash-openanalytics", + id: "openanalytics", version: "0.1.0", capabilities: ["hooks.page-fragments:register"], admin: { @@ -24,6 +24,7 @@ export function createPlugin() { "validate-connection": { methods: ["POST"], permission: "plugins:manage", + request: { body: "bytes", maxBytes: 4_096 }, handler: validateConnection, }, }, diff --git a/src/settings/schema.ts b/src/settings/schema.ts index e9e6267..b1bef69 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -7,7 +7,8 @@ export const settingsSchema = { apiUrl: { type: "url", label: "OpenAnalytics API URL", - description: "API origin for your OpenAnalytics account.", + description: + "API URL for your OpenAnalytics account. HTTPS is recommended; HTTP sends your private read key without encryption. Use HTTPS for production.", default: DEFAULT_API_URL, }, privateReadKey: { diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 3c0899f..b6c65c3 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -16,7 +16,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createPlugin } from "../src/plugin"; const runtimes: Array<{ runtime: EmDashRuntime; directory: string }> = []; -const pluginId = "emdash-openanalytics"; +const pluginId = "openanalytics"; const privateKey = "oa_sk_admin_test_private_key"; const encryptionKey = `emdash_enc_v1_${Buffer.alloc(32, 12).toString("base64url")}`; const apiUrl = "https://api.openanalytics.test"; @@ -51,7 +51,7 @@ const responseMeta = () => { async function makeRuntime() { vi.stubEnv("EMDASH_ENCRYPTION_KEY", encryptionKey); - const directory = mkdtempSync(join(tmpdir(), "emdash-openanalytics-admin-test-")); + const directory = mkdtempSync(join(tmpdir(), "openanalytics-admin-test-")); const runtime = await EmDashRuntime.create({ config: { database: { @@ -312,7 +312,7 @@ async function renderedTracking(runtime: EmDashRuntime) { async function validate( runtime: EmDashRuntime, - options: { role?: number; tokenScopes?: string[] } = {}, + options: { role?: number; tokenScopes?: string[]; body?: string; contentLength?: boolean } = {}, ) { const response = await dispatchPluginApiRequest({ runtime, @@ -320,6 +320,10 @@ async function validate( path: "validate-connection", request: new Request(`https://cms.test/_emdash/api/plugins/${pluginId}/validate-connection`, { method: "POST", + ...(options.body === undefined || options.contentLength !== true + ? {} + : { headers: { "content-length": String(Buffer.byteLength(options.body)) } }), + ...(options.body === undefined ? {} : { body: options.body }), }), ...(options.role === undefined ? {} @@ -402,6 +406,31 @@ describe("OpenAnalytics native admin page", () => { expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); }); + it("bounds standalone validation request bodies before upstream calls", async () => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey }); + const oversized = await validate(runtime, { + role: 50, + tokenScopes: ["admin"], + body: "x".repeat(5_000), + contentLength: true, + }); + expect(oversized.status).toBe(413); + expect(fetchMock).not.toHaveBeenCalled(); + const streamed = await validate(runtime, { + role: 50, + tokenScopes: ["admin"], + body: "x".repeat(5_000), + }); + expect(streamed.status).toBe(413); + expect(fetchMock).not.toHaveBeenCalled(); + + const emptyBody = await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + expect(emptyBody.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("requires the protected admin route and rejects CSRF and insufficient roles", async () => { const runtime = await makeRuntime(); const { fetchMock } = installFetch(); @@ -454,7 +483,7 @@ describe("OpenAnalytics native admin page", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("shows not-configured and configuration-changed states without making upstream calls", async () => { + it("shows missing configuration without calls and automatically validates changed settings", async () => { const runtime = await makeRuntime(); const { fetchMock, requests, failNextSite } = installFetch(); const unconfigured = await dispatchAdmin( @@ -464,6 +493,9 @@ describe("OpenAnalytics native admin page", () => { ); expect(unconfigured.response.status).toBe(200); expect(JSON.stringify(unconfigured.data)).toContain("Not configured"); + expect(JSON.stringify(unconfigured.data)).toContain("private read key"); + expect(JSON.stringify(unconfigured.data)).not.toContain("Refresh connection"); + expect(JSON.stringify(unconfigured.data)).not.toContain("Retry connection"); expect(fetchMock).not.toHaveBeenCalled(); await setSettings(runtime, { @@ -477,14 +509,14 @@ describe("OpenAnalytics native admin page", () => { apiUrl: "https://new-api.openanalytics.test", trackingEnabled: true, }); - const stale = await dispatchAdmin( + const refreshed = await dispatchAdmin( runtime, { type: "page_load", page: "/analytics" }, { role: 50, tokenScopes: ["admin"] }, ); - expect(JSON.stringify(stale.data)).toContain("Configuration changed"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(JSON.stringify(stale.data)).not.toContain(privateKey); + expect(JSON.stringify(refreshed.data)).toContain("Connected"); + expect(fetchMock).toHaveBeenCalledTimes(6); + expect(JSON.stringify(refreshed.data)).not.toContain(privateKey); failNextSite("network"); const failedRevalidation = await dispatchAdmin( runtime, @@ -497,14 +529,204 @@ describe("OpenAnalytics native admin page", () => { { role: 50, tokenScopes: ["admin"] }, ); expect(JSON.stringify(failedRevalidation.data)).not.toContain(privateKey); - const stillStale = await dispatchAdmin( + const remainsConnected = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(remainsConnected.data)).toContain("Connected"); + expect(requests.filter(({ url }) => url.includes("analytics/"))).toHaveLength(8); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("automatically validates once on first page load, then range changes and revisits reuse the snapshot", async () => { + const runtime = await makeRuntime(); + const { requests } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey, trackingEnabled: true }); + const first = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + expect(JSON.stringify(first.data)).toContain("Connected"); + expect(JSON.stringify(first.data)).toContain("Visitors"); + const blocks = first.data.blocks ?? []; + const connectionButtonIndex = blocks.findIndex( + (block) => block.type === "actions" && JSON.stringify(block).includes("Refresh connection"), + ); + const rangeIndex = blocks.findIndex( + (block) => block.type === "actions" && JSON.stringify(block).includes('"label":"Date range"'), + ); + expect(connectionButtonIndex).toBeGreaterThan(-1); + expect(rangeIndex).toBeGreaterThan(connectionButtonIndex); + expect(JSON.stringify(blocks[rangeIndex])).not.toContain("revalidate"); + expect(requests.filter(({ url }) => url.endsWith("/v1/read/site"))).toHaveLength(1); + expect(requests.filter(({ url }) => url.includes("analytics/"))).toHaveLength(4); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + await dispatchAdmin( + runtime, + { type: "block_action", action_id: "range", value: "7d", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + const afterRange = requests.filter(({ url }) => url.includes("analytics/")).slice(-4); + for (const { url } of afterRange) { + expect(Date.now() - Date.parse(new URL(url).searchParams.get("from")!)).toBeLessThan( + 8 * 24 * 60 * 60 * 1000, + ); + } + const refresh = await dispatchAdmin( + runtime, + { type: "block_action", action_id: "revalidate", value: { range: "7d" }, page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(refresh.data)).toContain("Connected"); + const afterRefresh = requests.filter(({ url }) => url.includes("analytics/")).slice(-4); + for (const { url } of afterRefresh) { + expect(Date.now() - Date.parse(new URL(url).searchParams.get("from")!)).toBeLessThan( + 8 * 24 * 60 * 60 * 1000, + ); + } + await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + expect(requests.filter(({ url }) => url.endsWith("/v1/read/site"))).toHaveLength(2); + expect(requests.filter(({ url }) => url.includes("analytics/"))).toHaveLength(16); + }); + + it("suppresses a stale tracker on configuration mismatch until the new pair validates", async () => { + const runtime = await makeRuntime(); + const { requests } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey, trackingEnabled: true }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + + const changedKey = "oa_sk_admin_test_changed_private_key"; + await setSettings(runtime, { + apiUrl: "https://new-api.openanalytics.test", + privateReadKey: changedKey, + trackingEnabled: true, + }); + expect(await renderedTracking(runtime)).toBe(""); + const beforeLoad = requests.length; + const page = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(page.data)).toContain("Connected"); + expect( + requests.slice(beforeLoad).filter(({ url }) => url.endsWith("/v1/read/site")), + ).toHaveLength(1); + expect(requests.slice(beforeLoad).filter(({ url }) => url.includes("analytics/"))).toHaveLength( + 4, + ); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("does not retry a failed changed configuration until explicit retry, then recovers", async () => { + const runtime = await makeRuntime(); + const { requests, failNextSite } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey, trackingEnabled: true }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + await setSettings(runtime, { + apiUrl: "https://new-api.openanalytics.test", + privateReadKey: "oa_sk_admin_test_changed_private_key", + trackingEnabled: true, + }); + failNextSite("network"); + const failed = await dispatchAdmin( runtime, { type: "page_load", page: "/analytics" }, { role: 50, tokenScopes: ["admin"] }, ); - expect(JSON.stringify(stillStale.data)).toContain("Validate the connection"); + expect(JSON.stringify(failed.data)).toContain("Retry connection"); + expect(await renderedTracking(runtime)).toBe(""); + const afterFailure = requests.length; + for (const interaction of [ + { type: "page_load", page: "/analytics" }, + { type: "block_action", action_id: "range", value: "7d", page: "/analytics" }, + ]) { + await dispatchAdmin(runtime, interaction, { role: 50, tokenScopes: ["admin"] }); + } + expect(requests).toHaveLength(afterFailure); + expect(await renderedTracking(runtime)).toBe(""); + const retried = await dispatchAdmin( + runtime, + { type: "block_action", action_id: "revalidate", value: { range: "7d" }, page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(retried.data)).toContain("Connected"); + expect( + requests.slice(afterFailure).filter(({ url }) => url.endsWith("/v1/read/site")), + ).toHaveLength(1); + expect( + requests.slice(afterFailure).filter(({ url }) => url.includes("analytics/")), + ).toHaveLength(4); + expect( + requests + .slice(afterFailure) + .filter(({ url }) => url.includes("analytics/")) + .every( + ({ url }) => + new URL(url).searchParams.get("from") && + Date.parse(new URL(url).searchParams.get("from")!) > + Date.now() - 8 * 24 * 60 * 60 * 1000, + ), + ).toBe(true); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("shows a safe retry after automatic validation fails and makes no analytics reads", async () => { + const runtime = await makeRuntime(); + const { requests, failNextSite } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey }); + failNextSite(401); + const failed = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + const failedText = JSON.stringify(failed.data); + expect(failedText).toContain("Connection failed"); + expect(failedText).toContain("Retry connection"); + expect(failedText).toContain("rejected this credential"); + expect(failedText).not.toContain(privateKey); expect(requests.filter(({ url }) => url.includes("analytics/"))).toHaveLength(0); expect(await renderedTracking(runtime)).toBe(""); + const revisited = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + expect(JSON.stringify(revisited.data)).toContain("Retry connection"); + expect(requests.filter(({ url }) => url.endsWith("/v1/read/site"))).toHaveLength(1); + const retried = await dispatchAdmin( + runtime, + { type: "block_action", action_id: "revalidate", page: "/analytics" }, + { + role: 50, + tokenScopes: ["admin"], + }, + ); + expect(JSON.stringify(retried.data)).toContain("Connected"); + expect(requests.filter(({ url }) => url.endsWith("/v1/read/site"))).toHaveLength(2); }); it("loads useful native blocks, emits explicit range and timezone, and never returns the private key", async () => { @@ -1042,7 +1264,7 @@ describe("OpenAnalytics native admin page", () => { { type: "page_load", page: "/analytics" }, { role: 50, tokenScopes: ["admin"] }, ); - expect(JSON.stringify(page.data)).toContain("Validate the connection"); + expect(JSON.stringify(page.data)).toContain("Retry connection"); expect(requests).toHaveLength(before); }); diff --git a/tests/client.test.ts b/tests/client.test.ts index 81fe764..5f3bc9b 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -111,6 +111,16 @@ describe("getSite", () => { } }); + it("ignores implausibly large Retry-After values", async () => { + mockFetch(429, {}, { "retry-after": "999999999999999999999999" }); + try { + await getSite(config()); + throw new Error("expected getSite to fail"); + } catch (error) { + expect(error).toMatchObject({ kind: "rate_limited", retryAfterSeconds: undefined }); + } + }); + it("rejects malformed JSON and malformed site contexts with safe errors", async () => { mockFetch(200, "oops"); await expectKind(getSite(config()), "invalid_response"); @@ -125,6 +135,62 @@ describe("getSite", () => { await expectKind(getSite(config()), "invalid_response"); }); + it("rejects oversized upstream JSON responses", async () => { + const oversized = new Response(`{"padding":"${"x".repeat(1_048_576)}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => oversized), + ); + await expectKind(getSite(config()), "invalid_response"); + }); + + it("rejects an oversized declared response and cancels its body before reading", async () => { + let cancelled = false; + const body = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); + const response = new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "1048577", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); + await expectKind(getSite(config()), "invalid_response"); + expect(cancelled).toBe(true); + }); + + it("cancels a streamed response as soon as its bytes exceed the cap", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1_048_577)); + }, + cancel() { + cancelled = true; + }, + }); + const response = new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); + await expectKind(getSite(config()), "invalid_response"); + expect(cancelled).toBe(true); + }); + it.each([ { ...goodSite, install: undefined }, { ...goodSite, site_id: null }, diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 899f016..498cc0b 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -20,7 +20,7 @@ const PRIVATE_KEY = "oa_sk_test-credential-must-stay-server-side"; const ENCRYPTION_KEY = `emdash_enc_v1_${Buffer.alloc(32, 11).toString("base64url")}`; async function makeRuntime() { - const directory = mkdtempSync(join(tmpdir(), "emdash-openanalytics-test-")); + const directory = mkdtempSync(join(tmpdir(), "openanalytics-test-")); const databasePath = join(directory, "test.sqlite"); const dependencies = { config: { @@ -88,7 +88,7 @@ describe("OpenAnalytics native EmDash runtime", () => { it("fails closed when EmDash has no plugin secret encryption key", async () => { vi.stubEnv("EMDASH_ENCRYPTION_KEY", ""); const runtime = await makeRuntime(); - const plugin = runtime.configuredPlugins.find(({ id }) => id === "emdash-openanalytics"); + const plugin = runtime.configuredPlugins.find(({ id }) => id === "openanalytics"); if (!plugin?.admin?.settingsSchema) throw new Error("OpenAnalytics settings schema was not registered"); @@ -115,7 +115,7 @@ describe("OpenAnalytics native EmDash runtime", () => { it("stores the secret encrypted, requires admin permission, validates the site, then renders its tracker", async () => { vi.stubEnv("EMDASH_ENCRYPTION_KEY", ENCRYPTION_KEY); const runtime = await makeRuntime(); - const plugin = runtime.configuredPlugins.find(({ id }) => id === "emdash-openanalytics"); + const plugin = runtime.configuredPlugins.find(({ id }) => id === "openanalytics"); expect(plugin).toBeDefined(); if (!plugin) return; const schema = plugin.admin?.settingsSchema; @@ -347,7 +347,7 @@ describe("OpenAnalytics native EmDash runtime", () => { it("never calls OpenAnalytics for EmDash admin URLs", async () => { const runtime = await makeRuntime(); - const plugin = runtime.configuredPlugins.find(({ id }) => id === "emdash-openanalytics"); + const plugin = runtime.configuredPlugins.find(({ id }) => id === "openanalytics"); if (!plugin) throw new Error("OpenAnalytics plugin was not registered"); const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy);