diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c404848..f1483c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,11 @@ jobs: matrix: node-version: [22, 24] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 10.32.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: pnpm diff --git a/README.md b/README.md index 29ce532..03155c6 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ Native EmDash CMS integration for OpenAnalytics, by Black Swamp AI. -This initial scaffold provides configuration and connection validation, encrypted -server-side credential storage through EmDash, and public-site tracker installation. -Embedded analytics UI is planned and is outside this release. +Native connection validation, public-site tracker installation, and an analytics +overview inside EmDash. The admin page uses EmDash Block Kit controls, metric +cards, notices, and a timeseries chart. Private credentials stay on the server. ## Installation Requires EmDash 1.0.1 or later in the 1.x series and Node.js 22.16 or later. -This scaffold has not been published to npm. For local testing, run `pnpm install` +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 @@ -50,18 +50,21 @@ render the tracker. See [EmDash's page fragment guide](https://docs.emdashcms.co ## OpenAnalytics setup 1. Create a **private read key** in OpenAnalytics for the site you want to track. - Request `site:read` and `analytics:read`. This scaffold uses only `site:read`; - `analytics:read` prepares the key for the future embedded analytics UI. + 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, and tracking switch. Tracking defaults to enabled, but no - tracker appears before a successful connection validation. -4. Validate the saved connection with the authenticated server route below. + 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 initial scaffold exposes a server route instead of a setup wizard. From the -browser console on your EmDash admin page, while signed in as an administrator: +The existing protected validation route also remains available to administrators: ```js const response = await fetch("/_emdash/api/plugins/emdash-openanalytics/validate-connection", { @@ -76,11 +79,48 @@ 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. +## Analytics overview + +The OpenAnalytics page shows visitors, pageviews, and events from the aggregate +overview response, plus visitors and pageviews over time. Choose **Last 24 hours**, +**Last 7 days**, **Last 30 days** (default), or **Last 90 days**. Requests send +explicit UTC bounds and the configured IANA timezone. Overview totals use hourly +rollups; the chart uses hourly buckets for 24 hours or daily buckets for longer +ranges. Visitor buckets are displayed +as returned, never summed into the aggregate visitor metric. + +Metric cards show previous-period totals when supplied by OpenAnalytics. These +are server-provided aggregate comparisons; the plugin does not calculate +percentages or infer comparisons from chart buckets. + +OpenAnalytics can snap bounds down to available rollup boundaries; the page +shows the effective queried periods. Chart buckets follow the configured +timezone, while native chart tick labels and tooltips use the browser timezone. + +Freshness information shows the latest rolled-up data and pipeline status. +Stale, degraded, imported, or partial results receive notices so temporarily low +numbers are easier to interpret. Missing freshness is shown as unavailable. +Authentication failures, missing analytics scope, suspended service, rate limits, +and unavailable upstream service have safe messages and validation/retry controls. + +Opening the page or changing its range makes two server-side reads: overview and +timeseries. Revalidation first reads site metadata and refreshes the installation +snapshot. There is no polling, automatic retry, or shared analytics cache. The +private admin route requires `plugins:manage` and EmDash's CSRF protection. + Validation saves the returned public installation configuration. Public page requests use this saved configuration without calling the OpenAnalytics read API. -Changing the API URL or private key stops injection until you validate again. -A failed validation clears the saved connection. Turning tracking off suppresses -injection immediately; turning it on uses the existing valid connection. +The snapshot is bound to the exact normalized API URL and private key. A +different configuration suppresses tracking; restoring the exact validated +configuration makes its matching snapshot usable again. Removing the private +key or turning tracking off suppresses injection immediately. + +Temporary network failures, timeouts, rate limits, and service errors during +revalidation preserve a matching last-known-good snapshot. The admin shows the +error while tracking continues from that snapshot. Rejected credentials and +invalid installation responses invalidate the connection. Successful validation +replaces the saved snapshot. A snapshot never enables tracking for a different +API URL or credential. ## Security @@ -112,7 +152,8 @@ manual tracker URL overrides. ## Current limitations -- No analytics dashboard, custom admin page, automatic refresh, polling, or retries. +- The overview is intentionally small: no top pages, sources, sessions, funnels, + revenue, visitor profiles, editor analytics, or realtime polling. - Revalidate after tracker rotation or a collector URL change. Saved installation metadata has no automatic expiry; private-key revocation is detected on validation. - Static pages receive the snapshot available when they are rendered. Rebuild @@ -133,6 +174,8 @@ pnpm check verification. CI runs the same checks. No npm publication is performed. Source boundaries are the native plugin entry, settings/configuration, the -server-side OpenAnalytics client, saved connection state, and tracker fragments. +server-side OpenAnalytics client, saved connection state, tracker fragments, +and native admin page. See [verified upstream contracts](docs/upstream-contracts.md) for versions and -the native-plugin/security decisions. MIT licensed; no OpenAnalytics source is bundled. +the native-plugin/security decisions and [implementation footprint](docs/implementation-footprint.md) +for the comparison with the n8n integration. MIT licensed; no OpenAnalytics source is bundled. diff --git a/docs/implementation-footprint.md b/docs/implementation-footprint.md new file mode 100644 index 0000000..923ced1 --- /dev/null +++ b/docs/implementation-footprint.md @@ -0,0 +1,45 @@ +# Implementation footprint + +Measured on 2026-09-29. Counts are physical TypeScript lines, including comments +and blank lines, using checked-in source rather than build output. Production +counts include `src/**/*.ts` for EmDash and `nodes/**/*.ts` plus +`credentials/**/*.ts` for n8n. Test counts include `tests/**/*.ts`, including +fixtures/helpers. Lockfiles, generated code, docs, CI, and package tooling are +excluded. These are size comparisons, not runtime performance measurements. + +| Implementation | Production LOC | Test LOC | Production modules | OpenAnalytics HTTP endpoints | +| ----------------------------- | -------------: | -------: | -----------------: | ---------------------------: | +| EmDash scaffold, PR #1 | 495 | 721 | 9 | 1 | +| EmDash native overview, PR #2 | 1458 | 1923 | 10 | 3 | +| n8n OpenAnalytics 0.1.1 | 607 | 613 | 5 | 11 | + +PR #2 adds **963 production lines** and **1202 test lines** +over the scaffold. The scaffold tree at `a9e69a2` is identical to the original +`c7c8efe` tree. The comparison uses n8n commit +`8caaa20c6385f6fba1fcf2e8c2dbec0a1ac5efb1` from the local +`@blackswampai/n8n-nodes-openanalytics` checkout. + +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 +package exposes a broader set of declarative read operations through n8n's +workflow editor; it does not install a public tracker or render a site overview. +The size difference reflects those different responsibilities. + +EmDash production modules remain narrowly scoped: plugin/descriptor, connection +state, configuration/settings, OpenAnalytics transport/errors/types, tracker +fragment, and one admin page. PR #2 adds no production dependencies, custom +browser bundle, chart library, application framework, polling, or shared cache. +`@emdash-cms/blocks@1.0.1` is a development dependency for type-only authoring and +upstream response validation in tests; EmDash provides its renderer at runtime. + +Analytics use exactly two HTTP reads per requested overview: overview (including +its server-provided preceding-period totals) and timeseries. Revalidation adds +one site read. Public page rendering adds no read-key requests. A transient +revalidation failure returns its error and preserves a matching tracker +snapshot without starting additional analytics requests. + +To reproduce the line counts, enumerate the directories above, include only +`.ts` files, and sum their physical lines. For baseline comparison, read those +same files from `git show a9e69a2:`; for n8n, read files at the pinned commit. +Apply the same formatting/measurement convention in future PRs. diff --git a/docs/upstream-contracts.md b/docs/upstream-contracts.md index a2c2a8c..ad7bafd 100644 --- a/docs/upstream-contracts.md +++ b/docs/upstream-contracts.md @@ -6,6 +6,11 @@ Inspected on 2026-09-29 before implementation: core package version 1.0.1. Tests and build use the published `emdash@1.0.1`. - OpenAnalytics [`f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9`](https://github.com/OpenLabs-so/openanalytics/tree/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9). +Rechecked for the native admin PR using current official docs, installed package +source, and `git ls-remote HEAD` for both upstream repositories. These remain +the current upstream HEAD revisions. Search engine commit listings can be stale; +the contracts below follow the source at those revisions. + ## EmDash The [native-plugin tutorial](https://docs.emdashcms.com/plugins/creating-native-plugins/your-first-native-plugin/) @@ -36,10 +41,31 @@ authentication, CSRF checks, and response envelopes. This plugin uses a private POST route with `plugins:manage`. [Block Kit](https://docs.emdashcms.com/plugins/creating-plugins/block-kit/) -is supported by native plugins through an admin interaction route, declared page -or widget metadata, and JSON block responses. Native React pages/widgets require -separate descriptor/runtime entries and an admin module. Neither is needed for -this scaffold; Block Kit remains a suitable candidate for PR #2. +supports native plugins with `admin.pages`, a private POST `admin` route, and +JSON `BlockResponse` results. No admin entry module or browser plugin code is +needed. The host posts `page_load` with `page`, or `block_action` with `action_id`, +`value`, and `page`; replacement blocks update the interface. This plugin checks +the declared page and allowed actions/ranges before any upstream request. + +The published `@emdash-cms/blocks@1.0.1` types define headers, fields, actions, +selects/buttons, stats, banners, context, and a native timeseries chart with +`config.chart_type: "timeseries"` and `[timestamp_ms, value]` points. The host +owns chart rendering, typography, spacing, navigation, and initial loading. +Forms, tables (including badge cells), and tabs are available but unnecessary +for this page. There is no standalone status badge or plugin-owned loading +block; banners/fields represent connection state. + +Only Block Kit **types** are imported in production; the blocks package is a +development dependency and its React/chart renderer is not bundled. Tests use +the upstream block validator because trusted native responses do not receive +the sandboxed response validation policy automatically. + +Installed runtime source `EmDashRuntime.resolveTrustedUiContext` supplies native +admin locale/direction for declared pages. Neither `ctx.ui` nor `ctx.site` +includes a timezone. Plugin settings are scoped to the plugin; the host +`site:timezone` is not exposed through them. The overview therefore provides a +small IANA timezone setting, defaults to UTC, and displays the timezone. It does +not query internal host tables or infer timezone from content locale. Upstream tests use Vitest. The published `emdash/internal/plugin-test-runtime` exposes the runtime, route dispatcher, and @@ -52,7 +78,7 @@ production code. The [CMS/WordPress contract](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/docs/wordpress/README.md) specifies Bearer authentication with a site-bound private read key and the exact tracker attributes `data-key` and `data-collector`. -`GET /v1/read/site` requires `site:read`. Future analytics reads require +`GET /v1/read/site` requires `site:read`. Analytics reads require `analytics:read`, which older/default keys do not necessarily carry. Verified implementation: `apps/api/src/http/read-key.ts`; schema: @@ -72,3 +98,59 @@ model. The current metadata route deliberately permits suspended sites so integrations can obtain installation details and show status. Analytics reads have a separate suspended-site gate. The client still normalizes HTTP 402 for compatibility, along with 401, 403, 404, 429, and service errors. + +### Overview and timeseries + +Verified against the pinned [OpenAPI schemas](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/packages/contracts/openapi/openapi.yaml) +and [read-key route implementation](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/apps/api/src/http/read-key.ts): + +- `GET /v1/read/analytics/overview`: `meta`, aggregate `totals` containing + `visitors`, `pageviews`, `events`, and `billable_events`, and nullable + `comparison`. No sessions, duration, or bounce metric is supplied here. +- `GET /v1/read/analytics/timeseries`: `meta`, `series` of UTC `bucket` instants + with `visitors`, `pageviews`, and `events`, and nullable `comparison`. +- Both require explicit full UTC `from`/`to` instants for a half-open range and + an IANA `timezone`. This plugin explicitly sends `hour` for overview totals + across all four presets, and `hour` for the 24h chart or `day` for 7d/30d/90d. + Timeseries additionally supports `minute`/`week`; overview supports + `hour`/`day`. Automatic grain selection can + produce around 1,440 minute buckets for 24h, so it is deliberately avoided. +- The [aggregate resolver](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/apps/api/src/analytics/resolve.ts) + refuses forced overview `day` for non-UTC timezone offsets. Overview `hour` + reads the quarter-hour atom rollup and supports the full 90d preset (the + current default cap is 400d). Timeseries `day` can compose local days from + that rollup. These endpoints therefore intentionally use separate resolutions. + Effective bounds snap down to rollup boundaries: a UTC daily chart may end at + the last UTC midnight while hourly totals include more recent quarter-hours. + The UI exposes effective queried ranges instead of hiding that difference. +- Aggregate visitors come from overview, never from summing/rebucketing chart + points. Visitor identities rotate at UTC midnight; a visitor can count in + several buckets. Overview requests `compare=true` and displays the server's + preceding-period totals below each metric, with its `comparison_range`. + Timeseries does not request comparison. No client-calculated percentage, + summed bucket total, or separate comparison HTTP request is introduced. +- `meta.freshness` contains `state` (`ok`, `no_data`, `stale`, `degraded`), nullable + `watermark`, and `as_of`. The watermark is the site's latest rolled-up bucket, + potentially outside the requested range; it is not a guarantee that every + event through that instant has arrived. The UI labels it accordingly and + handles absent freshness metadata conservatively. +- `meta.accuracy` distinguishes `exact`, `estimated`, and `provider_defined`; + imported data can affect visitor totals. `partial` and `truncated` also affect + interpretation. These metadata flags receive concise UI notices. +- HTTP 403 `FORBIDDEN` means missing scope; HTTP 403 `SITE_SUSPENDED` means the + site's analytics service is suspended. Only the fixed error code is inspected + to distinguish these states, never the upstream error message. HTTP 402 is + kept as a legacy billing mapping. HTTP 429 carries a `Retry-After` delay; + HTTP 503 denotes service unavailability. Invalid ranges or unsupported grain + can return HTTP 400. + +The CMS guide recommends reads on actual admin use and warns against sharing a +read-key response cache across administrators. Each overview interaction makes +only two analytics reads; validation explicitly adds one site read. No polling, +automatic retries, or extra read endpoints are introduced. + +The native timeseries chart has no timezone formatting option. OpenAnalytics +aligns the returned buckets to the configured timezone, and the page formats +range/freshness text in that timezone, but native chart tick/tooltips use the +administrator's browser timezone. The UI discloses this and uses a neutral axis +label; timestamps are never shifted to fake timezone formatting. diff --git a/package.json b/package.json index 097d035..35c0a10 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "prepublishOnly": "npm run check" }, "devDependencies": { + "@emdash-cms/blocks": "1.0.1", "@types/node": "24.10.1", "emdash": "1.0.1", "oxfmt": "0.59.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff96e30..8b6fef2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@emdash-cms/blocks': + specifier: 1.0.1 + version: 1.0.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@types/node': specifier: 24.10.1 version: 24.10.1 diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 89dd345..4368e4c 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -21,6 +21,12 @@ assert.equal(plugin.admin.settingsSchema.privateReadKey.type, "secret"); 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.equal(typeof plugin.routes.admin.handler, "function"); +assert.equal(plugin.routes.admin.permission, "plugins:manage"); +assert.deepEqual(plugin.routes.admin.methods, ["POST"]); +assert(plugin.admin.pages.some((page) => page.path === "/analytics")); +assert.equal(plugin.admin.entry, undefined); // Prefix checks and examples are expected; a concrete private credential is not. for (const file of await readdir(resolve(root, "dist"))) { diff --git a/src/admin/page.ts b/src/admin/page.ts new file mode 100644 index 0000000..d8d4baa --- /dev/null +++ b/src/admin/page.ts @@ -0,0 +1,557 @@ +import type { Block, BlockResponse } from "@emdash-cms/blocks"; +import type { RouteContext } from "emdash"; + +import { + configurationFingerprint, + configurationFromSettings, + isSiteSnapshot, + safeConnectionSummary, + SITE_SNAPSHOT_KEY, + type SiteSnapshot, + validateConnection, +} from "../connection"; +import { containsPrivateKey, getOverview, getTimeseries } from "../openanalytics/client"; +import { OpenAnalyticsError } from "../openanalytics/errors"; +import type { + AnalyticsReadQuery, + AnalyticsOverviewResponse, + AnalyticsTimeseriesResponse, +} from "../openanalytics/types"; +import { DEFAULT_API_URL, type OpenAnalyticsConfig } from "../settings/config"; + +const RANGES = ["24h", "7d", "30d", "90d"] as const; +type DateRange = (typeof RANGES)[number]; +type Input = + | { type: "page_load"; page: string } + | { type: "block_action"; action_id: string; value?: unknown; page?: string }; + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function parseInput(value: unknown): Input | null { + if (!record(value)) return null; + if (value.type === "page_load" && value.page === "/analytics") return value as Input; + if ( + value.type === "block_action" && + typeof value.action_id === "string" && + ["range", "revalidate", "retry"].includes(value.action_id) && + (value.page === undefined || value.page === "/analytics") + ) + return value as Input; + return null; +} + +function range(value: unknown): DateRange { + if (typeof value === "string" && RANGES.includes(value as DateRange)) return value as DateRange; + if (record(value) && typeof value.range === "string" && RANGES.includes(value.range as DateRange)) + return value.range as DateRange; + return "30d"; +} + +function displayApiUrl(value: unknown): string { + if (typeof value !== "string" || !value.trim()) return "Not configured"; + if (containsPrivateKey(value)) return "Invalid API URL"; + try { + const parsed = new URL(value.trim()); + if ( + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !["http:", "https:"].includes(parsed.protocol) + ) + return "Invalid API URL"; + return parsed.toString().replace(/\/$/, ""); + } catch { + return "Invalid API URL"; + } +} + +function queryFor(selected: DateRange, tz: string, now = Date.now()): AnalyticsReadQuery { + const ms: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + "90d": 90 * 24 * 60 * 60 * 1000, + }; + return { + from: new Date(now - ms[selected]).toISOString(), + to: new Date(now).toISOString(), + timezone: tz, + resolution: selected === "24h" ? "hour" : "day", + }; +} + +function label(selected: DateRange): string { + return { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", + }[selected]; +} + +function safeError(kind: unknown, retryAfterSeconds?: number): string { + switch (kind) { + 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."; + case "forbidden": + return "This private key does not have permission to read OpenAnalytics data."; + case "billing": + return "OpenAnalytics paused this request because of a billing or service issue."; + case "rate_limited": + return retryAfterSeconds === undefined + ? "OpenAnalytics rate limit reached. Try again shortly." + : `OpenAnalytics rate limit reached. Try again in ${retryAfterSeconds} seconds.`; + case "range_invalid": + return "OpenAnalytics could not provide data for this date range. Choose another range."; + case "resolution_unavailable": + return "OpenAnalytics cannot provide this range and timezone at the requested resolution."; + case "network": + case "timeout": + case "server": + return "OpenAnalytics is temporarily unavailable. Try again shortly."; + default: + return "OpenAnalytics could not load analytics. Revalidate the connection or try again shortly."; + } +} + +function errorCopy(error: unknown): string { + return error instanceof OpenAnalyticsError + ? safeError(error.kind, error.retryAfterSeconds) + : "OpenAnalytics is temporarily unavailable. Try again shortly."; +} + +function dateText(value: string | null, tz: string): string | null { + if (!value || !Number.isFinite(Date.parse(value))) return null; + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone: tz, + }).format(new Date(value)); +} + +function rangeText(value: { from: string; to: string }, tz: string): string { + const from = dateText(value.from, tz); + const to = dateText(value.to, tz); + return from && to ? `${from} – ${to}` : "unavailable"; +} + +function connectionBlocks(args: { + apiUrl: string; + site?: { + name: string; + status: string; + install: { hasTrackingKey: boolean; trackerReady: boolean }; + }; + trackingEnabled: boolean; + validatedAt?: string; + needsValidation?: boolean; + notConfigured?: boolean; + error?: string; +}): Block[] { + let title = args.notConfigured + ? "Not configured" + : args.needsValidation + ? "Needs validation" + : "Connected"; + let description = args.notConfigured + ? "Add an OpenAnalytics private read key in plugin settings to connect this site." + : args.needsValidation + ? "Validate your private read key to connect this EmDash site." + : "Last validation confirmed this connection."; + let variant: "default" | "alert" | "error" = + args.notConfigured || args.needsValidation ? "alert" : "default"; + if (args.error) { + title = "Connection needs attention"; + description = args.error; + variant = "error"; + } else if (args.site && args.site.status !== "active") { + title = `Connected · site ${args.site.status}`; + description = "OpenAnalytics reports that this site is not active."; + variant = "alert"; + } + const install = args.site?.install; + const tracking = !install?.hasTrackingKey + ? "No tracking key" + : !install.trackerReady + ? "Tracking installation incomplete" + : !args.trackingEnabled + ? "Tracking disabled" + : args.site?.status === "active" + ? "Tracking active" + : "Tracking inactive"; + return [ + { type: "banner", title, description, variant }, + { + type: "fields", + fields: [ + { label: "Site", value: args.site?.name ?? "—" }, + { label: "Tracking", value: args.site ? tracking : "Not validated" }, + { label: "API", value: args.apiUrl }, + { + label: "Last validated", + value: args.site + ? (dateText(args.validatedAt ?? null, "UTC") ?? "Not recorded") + : "Never", + }, + ], + }, + ]; +} + +function controls( + selected: DateRange, + options: { validate?: boolean; retry?: boolean } = {}, +): Block { + const elements: Extract["elements"] = [ + { + type: "select", + action_id: "range", + label: "Date range", + initial_value: selected, + options: RANGES.map((value) => ({ label: label(value), value })), + }, + ]; + if (options.retry) + elements.push({ + type: "button", + action_id: "retry", + label: "Retry analytics", + 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, + }; +} + +function waitingPage( + selected: DateRange, + apiUrl: string, + trackingEnabled: boolean, + message: string, + needsValidation = true, + notConfigured = false, +): BlockResponse { + return { + blocks: [ + { type: "header", text: "OpenAnalytics" }, + ...connectionBlocks({ + apiUrl, + trackingEnabled, + needsValidation, + notConfigured, + error: needsValidation || notConfigured ? undefined : message, + }), + controls(selected, { + validate: notConfigured || (needsValidation && message.startsWith("Validate")), + }), + ...(needsValidation ? [{ type: "context" as const, text: message }] : []), + ], + }; +} + +function getMetaWarning( + response: AnalyticsOverviewResponse, + timeseries: AnalyticsTimeseriesResponse, +): string | null { + const metas = [response.meta, timeseries.meta]; + const unavailable = metas.some((meta) => meta.freshness === null); + const delayed = metas.some( + (meta) => + meta.freshness?.state === "stale" || + meta.freshness?.state === "degraded" || + meta.partial || + meta.truncated, + ); + const imported = metas.some((meta) => meta.data_sources.includes("imported")); + const estimated = metas.some((meta) => meta.accuracy !== "exact"); + if (metas.some((meta) => meta.freshness?.state === "no_data")) + return "No analytics data is available for part or all of this range."; + if (delayed && imported) + return "Some imported data may be delayed or incomplete while OpenAnalytics finishes processing."; + if (delayed) + return "Results may be delayed or incomplete while OpenAnalytics finishes processing events."; + if (imported && estimated) + return "This range includes imported analytics. Some values are estimated or follow the import provider's definitions."; + if (imported) return "This range includes imported analytics data."; + if (estimated) return "OpenAnalytics marks some values as estimated or provider-defined."; + if (unavailable) return "Freshness information is unavailable for part of this response."; + return null; +} + +async function loadAnalytics( + config: OpenAnalyticsConfig, + snapshot: SiteSnapshot, + selected: DateRange, + tz: string, + trackingEnabled: boolean, +): Promise { + const site = safeConnectionSummary(snapshot.site); + const query = queryFor(selected, tz); + const overviewQuery: AnalyticsReadQuery = { ...query, resolution: "hour", compare: true }; + let overview: AnalyticsOverviewResponse; + let timeseries: AnalyticsTimeseriesResponse; + try { + [overview, timeseries] = await Promise.all([ + getOverview(config, overviewQuery), + getTimeseries(config, query), + ]); + } catch (error) { + return { + blocks: [ + { type: "header", text: "OpenAnalytics" }, + ...connectionBlocks({ + apiUrl: config.apiUrl, + site, + trackingEnabled, + validatedAt: snapshot.validatedAt, + }), + controls(selected, { retry: true }), + { + type: "banner", + title: "Analytics unavailable", + description: errorCopy(error), + variant: "error", + }, + ], + }; + } + const blocks: Block[] = [ + { type: "header", text: "OpenAnalytics" }, + ...connectionBlocks({ + apiUrl: config.apiUrl, + site, + trackingEnabled, + validatedAt: snapshot.validatedAt, + }), + controls(selected), + { type: "header", text: label(selected) }, + { + type: "context", + text: `Buckets use ${tz}; chart timestamps are displayed in the browser timezone.`, + }, + { + type: "stats", + items: (["visitors", "pageviews", "events"] as const).map((metric) => ({ + label: { visitors: "Visitors", pageviews: "Pageviews", events: "Events" }[metric], + value: overview.totals[metric], + ...(overview.comparison + ? { description: `Previous period: ${overview.comparison.totals[metric]}` } + : {}), + })), + }, + { + type: "chart", + config: { + chart_type: "timeseries", + style: "line", + x_axis_name: "Time", + y_axis_name: "Count", + series: [ + { + name: "Visitors", + data: timeseries.series.map( + (p) => [Date.parse(p.bucket), p.visitors] as [number, number], + ), + }, + { + name: "Pageviews", + data: timeseries.series.map( + (p) => [Date.parse(p.bucket), p.pageviews] as [number, number], + ), + }, + ], + }, + }, + ]; + const freshness = overview.meta.freshness; + const watermark = freshness ? dateText(freshness.watermark, tz) : null; + blocks.push({ + type: "context", + text: watermark + ? `Latest rolled-up data: ${watermark}.` + : "Latest rolled-up data timestamp is unavailable.", + }); + blocks.push({ + type: "context", + text: `Totals cover ${rangeText(overview.meta.effective_range, tz)}. Chart covers ${rangeText(timeseries.meta.effective_range, tz)}.`, + }); + if (overview.comparison && overview.meta.comparison_range) { + blocks.push({ + type: "context", + text: `Previous period: ${rangeText(overview.meta.comparison_range, tz)}.`, + }); + } + if (overview.meta.freshness && timeseries.meta.freshness) { + const states = { + ok: "current", + no_data: "no data", + stale: "delayed", + degraded: "status unavailable", + }; + blocks.push({ + type: "context", + text: `Data status: totals ${states[overview.meta.freshness.state]}; chart ${states[timeseries.meta.freshness.state]}.`, + }); + } + const warning = getMetaWarning(overview, timeseries); + if (warning) + blocks.push({ type: "banner", title: "Data status", description: warning, variant: "alert" }); + return { blocks }; +} + +export async function renderAdminPage(ctx: RouteContext): Promise { + const interaction = parseInput(ctx.input); + if (!interaction) + return { + blocks: [ + { + type: "banner", + title: "Invalid request", + description: "Reload this page and try again.", + variant: "error", + }, + ], + }; + if ( + interaction.type === "block_action" && + interaction.action_id === "range" && + (typeof interaction.value !== "string" || !RANGES.includes(interaction.value as DateRange)) + ) { + return { + blocks: [ + { type: "header", text: "OpenAnalytics" }, + { + type: "banner", + title: "Invalid date range", + description: "Choose one of the available ranges and try again.", + variant: "error", + }, + ], + }; + } + const selected = interaction.type === "block_action" ? range(interaction.value) : "30d"; + const [apiUrlValue, key, trackingValue, timezoneValue] = await Promise.all([ + ctx.settings.get("apiUrl"), + ctx.settings.get("privateReadKey"), + ctx.settings.get("trackingEnabled"), + ctx.settings.get("timezone"), + ]); + const trackingEnabled = trackingValue !== false; + let config: OpenAnalyticsConfig; + try { + config = configurationFromSettings({ apiUrl: apiUrlValue, privateReadKey: key }); + } catch { + const configured = typeof key === "string" && !!key.trim(); + return waitingPage( + selected, + displayApiUrl(apiUrlValue ?? DEFAULT_API_URL), + trackingEnabled, + configured + ? "The OpenAnalytics API URL or private read key is invalid. Review plugin settings." + : "Add a private read key in plugin settings to connect OpenAnalytics.", + true, + !configured, + ); + } + const rawTimezone = typeof timezoneValue === "string" ? timezoneValue.trim() : ""; + let tz = "UTC"; + let timezoneError: string | null = null; + if (rawTimezone) { + try { + new Intl.DateTimeFormat("en", { timeZone: rawTimezone }).format(0); + tz = rawTimezone; + } catch { + timezoneError = + "Analytics timezone is invalid. Set an IANA timezone such as America/New_York in plugin settings."; + } + } + let validationError: string | null = null; + if (interaction.type === "block_action" && interaction.action_id === "revalidate") { + const validation = await validateConnection(ctx); + if (!validation.success) { + validationError = safeError( + validation.error.kind, + "retryAfterSeconds" in validation.error ? validation.error.retryAfterSeconds : undefined, + ); + } + } + 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) { + const site = safeConnectionSummary(snapshot.site); + return { + blocks: [ + { type: "header", text: "OpenAnalytics" }, + ...connectionBlocks({ + apiUrl: config.apiUrl, + site, + trackingEnabled, + validatedAt: snapshot.validatedAt, + }), + controls(selected), + { + type: "banner", + title: "Revalidation failed", + description: validationError, + variant: "error", + }, + ], + }; + } + return waitingPage(selected, config.apiUrl, trackingEnabled, validationError, false); + } + if (timezoneError) { + const blocks: Block[] = [ + { type: "header", text: "OpenAnalytics" }, + ...(isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint + ? connectionBlocks({ + apiUrl: config.apiUrl, + site: safeConnectionSummary(snapshot.site), + trackingEnabled, + validatedAt: snapshot.validatedAt, + }) + : connectionBlocks({ apiUrl: config.apiUrl, trackingEnabled, needsValidation: true })), + controls(selected), + { + type: "banner", + title: "Check analytics settings", + description: timezoneError, + variant: "error", + }, + ]; + return { blocks }; + } + if (!isSiteSnapshot(snapshot) || snapshot.fingerprint !== fingerprint) { + const stale = isSiteSnapshot(snapshot); + return waitingPage( + selected, + config.apiUrl, + trackingEnabled, + stale + ? "Configuration changed. Revalidate the connection to resume tracking and load analytics." + : "Validate the connection to load analytics.", + ); + } + return loadAnalytics(config, snapshot, selected, tz, trackingEnabled); +} diff --git a/src/connection.ts b/src/connection.ts index f9ea3c1..3545459 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,7 +1,10 @@ -import { containsPrivateKey, isSiteReadContext } from "./openanalytics/client"; +import type { PluginContext } from "emdash"; + +import { containsPrivateKey, getSite, isSiteReadContext } from "./openanalytics/client"; import { OpenAnalyticsError } from "./openanalytics/errors"; import type { SiteReadContext } from "./openanalytics/types"; import { parseConfiguration } from "./settings/config"; +import type { OpenAnalyticsConfig } from "./settings/config"; export const SITE_SNAPSHOT_KEY = "state:validated-site"; @@ -9,6 +12,8 @@ export interface SiteSnapshot { readonly version: 1; readonly fingerprint: string; readonly site: SiteReadContext; + /** Optional for compatibility with scaffold snapshots created before PR #2. */ + readonly validatedAt?: string; } /** Fingerprint the full credential pair without ever storing the credential. */ @@ -21,11 +26,62 @@ export async function configurationFingerprint( return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } +/** Validate the configured credentials server-side and refresh the public snapshot. */ +export async function validateConnection(ctx: PluginContext) { + let config: OpenAnalyticsConfig; + try { + const settings = { + apiUrl: await ctx.settings.get("apiUrl"), + privateReadKey: await ctx.settings.get("privateReadKey"), + }; + config = configurationFromSettings(settings); + } catch (error) { + try { + await ctx.kv.delete(SITE_SNAPSHOT_KEY); + } catch { + /* Never leak storage details to the admin response. */ + } + return { success: false as const, error: connectionError(error) }; + } + const fingerprint = await configurationFingerprint(config.apiUrl, config.readKey); + try { + const site = await getSite(config); + const snapshot: SiteSnapshot = { + version: 1, + fingerprint, + site, + validatedAt: new Date().toISOString(), + }; + await ctx.kv.set(SITE_SNAPSHOT_KEY, snapshot); + return { + success: true as const, + site: safeConnectionSummary(site, config.apiUrl), + validatedAt: snapshot.validatedAt, + }; + } catch (error) { + const existing = await ctx.kv.get(SITE_SNAPSHOT_KEY).catch(() => undefined); + const transient = + error instanceof OpenAnalyticsError && + ["network", "timeout", "rate_limited", "server"].includes(error.kind); + const retainKnownGood = + transient && isSiteSnapshot(existing) && existing.fingerprint === fingerprint; + if (!retainKnownGood) { + try { + await ctx.kv.delete(SITE_SNAPSHOT_KEY); + } catch { + /* Never leak storage details to the admin response. */ + } + } + return { success: false as const, error: connectionError(error) }; + } +} + export function configurationFromSettings(settings: unknown) { const source = settings && typeof settings === "object" && !Array.isArray(settings) ? (settings as Record) : {}; + if (containsPrivateKey(source.apiUrl)) throw new OpenAnalyticsError("configuration"); return parseConfiguration({ apiUrl: source.apiUrl, readKey: source.privateReadKey, @@ -38,10 +94,13 @@ export function isSiteSnapshot(value: unknown): value is SiteSnapshot { if ( snapshot.version !== 1 || typeof snapshot.fingerprint !== "string" || - !/^[a-f0-9]{64}$/.test(snapshot.fingerprint) + !/^[a-f0-9]{64}$/.test(snapshot.fingerprint) || + (snapshot.validatedAt !== undefined && + (typeof snapshot.validatedAt !== "string" || + !Number.isFinite(Date.parse(snapshot.validatedAt)))) ) return false; - return isSiteReadContext(snapshot.site) && !containsPrivateKey(snapshot.site); + return isSiteReadContext(snapshot.site) && !containsPrivateKey(snapshot); } function publicUrl(value: string | null): string | null { @@ -65,12 +124,13 @@ export function usableInstallation(install: SiteReadContext["install"]) { return { scriptUrl, collectorUrl, trackingKey }; } -export function safeConnectionSummary(site: SiteReadContext) { +export function safeConnectionSummary(site: SiteReadContext, apiUrl?: string) { return { siteId: site.site_id, slug: site.slug, name: site.name, status: site.status, + ...(apiUrl && publicUrl(apiUrl) ? { apiUrl: publicUrl(apiUrl) } : {}), install: { hasTrackingKey: !!site.install.tracking_key, scriptUrl: publicUrl(site.install.script_url), diff --git a/src/openanalytics/client.ts b/src/openanalytics/client.ts index 9037288..4742db0 100644 --- a/src/openanalytics/client.ts +++ b/src/openanalytics/client.ts @@ -1,9 +1,22 @@ import { type OpenAnalyticsConfig, parseConfiguration } from "../settings/config"; -import { errorForStatus, OpenAnalyticsError } from "./errors"; -import type { SiteReadContext } from "./types"; +import { analyticsErrorForStatus, errorForStatus, OpenAnalyticsError } from "./errors"; +import type { + AnalyticsDateRange, + AnalyticsFreshness, + AnalyticsMeta, + AnalyticsOverviewResponse, + AnalyticsReadQuery, + AnalyticsTimeseriesResponse, + OverviewTotals, + SiteReadContext, + TimeseriesPoint, +} from "./types"; const PRIVATE_KEY_VALUE = /oa_sk_[A-Za-z0-9_-]+/i; 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$/; export function containsPrivateKey(value: unknown, seen = new Set()): boolean { if (typeof value === "string") { @@ -32,16 +45,52 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + function nullableString(value: unknown): value is string | null { return value === null || typeof value === "string"; } +function isCount(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isUtcInstant(value: unknown): value is string { + return ( + typeof value === "string" && + ISO_UTC_INSTANT.test(value) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString().slice(0, 19) === value.slice(0, 19) + ); +} + +function isDateRange(value: unknown): value is AnalyticsDateRange { + return ( + isRecord(value) && + isUtcInstant(value.from) && + isUtcInstant(value.to) && + Date.parse(value.from) < Date.parse(value.to) + ); +} + +function isTimezone(value: unknown): value is string { + if (!isNonEmptyString(value) || value.length > 64) return false; + try { + const formatter = new Intl.DateTimeFormat("en", { timeZone: value }); + void formatter; + return true; + } catch { + return false; + } +} + export function isSiteReadContext(value: unknown): value is SiteReadContext { if (!isRecord(value) || !isRecord(value.install)) return false; const install = value.install; return ( - typeof value.site_id === "string" && - value.site_id.length > 0 && + isNonEmptyString(value.site_id) && typeof value.slug === "string" && typeof value.name === "string" && typeof value.status === "string" && @@ -52,14 +101,120 @@ export function isSiteReadContext(value: unknown): value is SiteReadContext { ); } -/** Read the site context associated with a private read key. */ -export async function getSite(config: OpenAnalyticsConfig): Promise { +function isTotals(value: unknown): value is OverviewTotals { + return ( + isRecord(value) && + isCount(value.events) && + isCount(value.pageviews) && + isCount(value.visitors) && + isCount(value.billable_events) + ); +} + +function isFreshness(value: unknown): value is AnalyticsFreshness { + return ( + isRecord(value) && + typeof value.state === "string" && + FRESHNESS_STATES.has(value.state) && + (value.watermark === null || isUtcInstant(value.watermark)) && + isUtcInstant(value.as_of) + ); +} + +function isMeta(value: unknown): value is AnalyticsMeta { + return ( + isRecord(value) && + isDateRange(value.requested_range) && + isDateRange(value.effective_range) && + isTimezone(value.timezone) && + typeof value.resolution === "string" && + RESOLUTIONS.has(value.resolution) && + Array.isArray(value.data_sources) && + value.data_sources.length > 0 && + value.data_sources.every((source) => source === "live" || source === "imported") && + (value.accuracy === "exact" || + value.accuracy === "estimated" || + value.accuracy === "provider_defined") && + (value.freshness === undefined || value.freshness === null || isFreshness(value.freshness)) && + (value.comparison_range === null || isDateRange(value.comparison_range)) && + typeof value.truncated === "boolean" && + typeof value.cached === "boolean" && + typeof value.partial === "boolean" + ); +} + +function isPoint(value: unknown): value is TimeseriesPoint { + return ( + isRecord(value) && + isUtcInstant(value.bucket) && + isCount(value.events) && + isCount(value.pageviews) && + isCount(value.visitors) + ); +} + +function projectRange(value: AnalyticsDateRange): AnalyticsDateRange { + return Object.freeze({ from: value.from, to: value.to }); +} + +function projectMeta(value: AnalyticsMeta): AnalyticsMeta { + return Object.freeze({ + requested_range: projectRange(value.requested_range), + effective_range: projectRange(value.effective_range), + timezone: value.timezone, + resolution: value.resolution, + data_sources: Object.freeze([...value.data_sources]), + accuracy: value.accuracy, + freshness: + value.freshness === null || value.freshness === undefined + ? null + : Object.freeze({ + state: value.freshness.state, + watermark: value.freshness.watermark, + as_of: value.freshness.as_of, + }), + comparison_range: value.comparison_range === null ? null : projectRange(value.comparison_range), + truncated: value.truncated, + cached: value.cached, + partial: value.partial, + }); +} + +function isAnalyticsReadQuery(value: AnalyticsReadQuery): boolean { + if ( + !isUtcInstant(value.from) || + !isUtcInstant(value.to) || + Date.parse(value.from) >= Date.parse(value.to) || + !isTimezone(value.timezone) || + (value.compare !== undefined && typeof value.compare !== "boolean") || + (value.resolution !== "hour" && value.resolution !== "day") + ) { + return false; + } + return true; +} + +async function getJSON( + config: OpenAnalyticsConfig, + path: string, + query?: AnalyticsReadQuery, +): Promise { const validated = parseConfiguration(config); - const base = validated.apiUrl; + if (query && !isAnalyticsReadQuery(query)) { + throw new OpenAnalyticsError("configuration", "OpenAnalytics analytics query is invalid."); + } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), validated.timeoutMs); try { - const response = await fetch(`${base}/v1/read/site`, { + const url = new URL(`${validated.apiUrl}${path}`); + if (query) { + url.searchParams.set("from", query.from); + url.searchParams.set("to", query.to); + url.searchParams.set("timezone", query.timezone); + url.searchParams.set("resolution", query.resolution); + if (query.compare !== undefined) url.searchParams.set("compare", String(query.compare)); + } + const response = await fetch(url.toString(), { method: "GET", headers: { Authorization: `Bearer ${validated.readKey}`, @@ -69,31 +224,25 @@ export async function getSite(config: OpenAnalyticsConfig): Promise { + const payload = await getJSON(config, "/v1/read/site"); + if (!isSiteReadContext(payload)) throw new OpenAnalyticsError("invalid_response"); + const site = Object.freeze({ + site_id: payload.site_id, + slug: payload.slug, + name: payload.name, + status: payload.status, + install: Object.freeze({ + tracking_key: payload.install.tracking_key, + script_url: payload.install.script_url, + collector_url: payload.install.collector_url, + }), + }); + if (containsPrivateKey(site)) throw new OpenAnalyticsError("invalid_response"); + return site; +} + +/** Read aggregate analytics using the key's server-side analytics:read scope. */ +export async function getOverview( + config: OpenAnalyticsConfig, + query: AnalyticsReadQuery, +): Promise { + const payload = await getJSON(config, "/v1/read/analytics/overview", query); + if ( + !isRecord(payload) || + !isMeta(payload.meta) || + !isTotals(payload.totals) || + !( + payload.comparison === null || + (isRecord(payload.comparison) && isTotals(payload.comparison.totals)) + ) + ) { + throw new OpenAnalyticsError("invalid_response"); + } + const comparison = payload.comparison; + const result: AnalyticsOverviewResponse = Object.freeze({ + meta: projectMeta(payload.meta), + totals: Object.freeze({ + events: payload.totals.events, + pageviews: payload.totals.pageviews, + visitors: payload.totals.visitors, + billable_events: payload.totals.billable_events, + }), + comparison: + comparison === null + ? null + : Object.freeze({ + totals: Object.freeze({ + events: (comparison as { totals: OverviewTotals }).totals.events, + pageviews: (comparison as { totals: OverviewTotals }).totals.pageviews, + visitors: (comparison as { totals: OverviewTotals }).totals.visitors, + billable_events: (comparison as { totals: OverviewTotals }).totals.billable_events, + }), + }), + }); + if (containsPrivateKey(result)) throw new OpenAnalyticsError("invalid_response"); + return result; +} + +/** Read chart buckets without client-side visitor reaggregation. */ +export async function getTimeseries( + config: OpenAnalyticsConfig, + query: AnalyticsReadQuery, +): Promise { + const payload = await getJSON(config, "/v1/read/analytics/timeseries", query); + const isPoints = (value: unknown): value is TimeseriesPoint[] => + Array.isArray(value) && value.every(isPoint); + if ( + !isRecord(payload) || + !isMeta(payload.meta) || + !isPoints(payload.series) || + !( + payload.comparison === null || + (isRecord(payload.comparison) && isPoints(payload.comparison.series)) + ) + ) { + throw new OpenAnalyticsError("invalid_response"); + } + const comparison = payload.comparison; + const projectPoints = (points: readonly TimeseriesPoint[]) => + Object.freeze( + points.map((point) => + Object.freeze({ + bucket: point.bucket, + events: point.events, + pageviews: point.pageviews, + visitors: point.visitors, + }), + ), + ); + const result: AnalyticsTimeseriesResponse = Object.freeze({ + meta: projectMeta(payload.meta), + series: projectPoints(payload.series), + comparison: + comparison === null + ? null + : Object.freeze({ + series: projectPoints((comparison as { series: TimeseriesPoint[] }).series), + }), + }); + if (containsPrivateKey(result)) throw new OpenAnalyticsError("invalid_response"); + return result; +} diff --git a/src/openanalytics/errors.ts b/src/openanalytics/errors.ts index de7edc6..5e95edf 100644 --- a/src/openanalytics/errors.ts +++ b/src/openanalytics/errors.ts @@ -2,6 +2,10 @@ export type OpenAnalyticsErrorKind = | "configuration" | "unauthorized" | "forbidden" + | "analytics_forbidden" + | "suspended" + | "range_invalid" + | "resolution_unavailable" | "billing" | "not_found" | "rate_limited" @@ -14,6 +18,12 @@ const MESSAGES: Record = { configuration: "OpenAnalytics configuration is invalid.", unauthorized: "OpenAnalytics rejected the read key. Check that it is current and valid.", forbidden: "This OpenAnalytics read key does not have permission to read site details.", + analytics_forbidden: "This OpenAnalytics read key does not have analytics:read permission.", + suspended: + "OpenAnalytics analytics are unavailable because this site is suspended. Check its OpenAnalytics account status.", + range_invalid: "OpenAnalytics could not read analytics for this date range.", + resolution_unavailable: + "OpenAnalytics cannot provide this chart resolution for the selected range and timezone.", billing: "OpenAnalytics site access is paused because of a billing issue.", not_found: "The OpenAnalytics site for this read key was not found.", rate_limited: "OpenAnalytics is receiving too many requests. Try again shortly.", @@ -43,6 +53,36 @@ export class OpenAnalyticsError extends Error { } } +function upstreamErrorCode(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const error = (payload as Record).error; + if (!error || typeof error !== "object" || Array.isArray(error)) return undefined; + const code = (error as Record).code; + return typeof code === "string" ? code : undefined; +} + +/** Map only stable, documented upstream codes; never expose upstream messages. */ +export function analyticsErrorForStatus( + status: number, + retryAfterHeader?: string | null, + payload?: unknown, +): OpenAnalyticsError { + const code = upstreamErrorCode(payload); + if (status === 403) { + if (code === "SITE_SUSPENDED") return new OpenAnalyticsError("suspended", undefined, status); + return new OpenAnalyticsError("analytics_forbidden", undefined, status); + } + if (status === 400) { + if (code === "RESOLUTION_NOT_AVAILABLE") { + return new OpenAnalyticsError("resolution_unavailable", undefined, status); + } + if (code === "VALIDATION_FAILED" || code === "RANGE_TOO_LARGE") { + return new OpenAnalyticsError("range_invalid", undefined, status); + } + } + return errorForStatus(status, retryAfterHeader); +} + export function errorForStatus( status: number, retryAfterHeader?: string | null, diff --git a/src/openanalytics/types.ts b/src/openanalytics/types.ts index 18ef33c..fbf906d 100644 --- a/src/openanalytics/types.ts +++ b/src/openanalytics/types.ts @@ -10,3 +10,67 @@ export interface SiteReadContext { readonly collector_url: string | null; }; } + +export type AnalyticsResolution = "minute" | "hour" | "day" | "week"; +export type AnalyticsReadResolution = "hour" | "day"; +export type AnalyticsFreshnessState = "ok" | "no_data" | "stale" | "degraded"; +export type AnalyticsDataSource = "live" | "imported"; +export type AnalyticsAccuracy = "exact" | "estimated" | "provider_defined"; + +export interface AnalyticsDateRange { + readonly from: string; + readonly to: string; +} + +export interface AnalyticsReadQuery extends AnalyticsDateRange { + readonly timezone: string; + readonly resolution: AnalyticsReadResolution; + readonly compare?: boolean; +} + +export interface AnalyticsFreshness { + readonly state: AnalyticsFreshnessState; + readonly watermark: string | null; + readonly as_of: string; +} + +/** Private read responses include freshness; public share responses do not. */ +export interface AnalyticsMeta { + readonly requested_range: AnalyticsDateRange; + readonly effective_range: AnalyticsDateRange; + readonly timezone: string; + readonly resolution: AnalyticsResolution; + readonly data_sources: readonly AnalyticsDataSource[]; + readonly accuracy: AnalyticsAccuracy; + readonly freshness: AnalyticsFreshness | null; + readonly comparison_range: AnalyticsDateRange | null; + readonly truncated: boolean; + readonly cached: boolean; + readonly partial: boolean; +} + +export interface OverviewTotals { + readonly events: number; + readonly pageviews: number; + readonly visitors: number; + readonly billable_events: number; +} + +export interface AnalyticsOverviewResponse { + readonly meta: AnalyticsMeta; + readonly totals: OverviewTotals; + readonly comparison: { readonly totals: OverviewTotals } | null; +} + +export interface TimeseriesPoint { + readonly bucket: string; + readonly events: number; + readonly pageviews: number; + readonly visitors: number; +} + +export interface AnalyticsTimeseriesResponse { + readonly meta: AnalyticsMeta; + readonly series: readonly TimeseriesPoint[]; + readonly comparison: { readonly series: readonly TimeseriesPoint[] } | null; +} diff --git a/src/plugin.ts b/src/plugin.ts index f592fee..34e2c31 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,13 +1,7 @@ import { definePlugin } from "emdash"; -import { - configurationFingerprint, - configurationFromSettings, - connectionError, - SITE_SNAPSHOT_KEY, - safeConnectionSummary, -} from "./connection"; -import { getSite } from "./openanalytics/client"; +import { renderAdminPage } from "./admin/page"; +import { validateConnection } from "./connection"; import { settingsSchema } from "./settings/schema"; import { trackingFragment } from "./tracking/fragment"; @@ -16,35 +10,21 @@ export function createPlugin() { id: "emdash-openanalytics", version: "0.1.0", capabilities: ["hooks.page-fragments:register"], - admin: { settingsSchema }, + admin: { + settingsSchema, + pages: [{ path: "/analytics", label: "OpenAnalytics", icon: "gauge" }], + }, routes: { + admin: { + methods: ["POST"], + permission: "plugins:manage", + request: { body: "json", maxBytes: 4_096 }, + handler: renderAdminPage, + }, "validate-connection": { methods: ["POST"], permission: "plugins:manage", - handler: async (ctx) => { - try { - const settings = { - apiUrl: await ctx.settings.get("apiUrl"), - privateReadKey: await ctx.settings.get("privateReadKey"), - }; - const config = configurationFromSettings(settings); - const site = await getSite(config); - const snapshot = { - version: 1 as const, - fingerprint: await configurationFingerprint(config.apiUrl, config.readKey), - site, - }; - await ctx.kv.set(SITE_SNAPSHOT_KEY, snapshot); - return { success: true, site: safeConnectionSummary(site) }; - } catch (error) { - try { - await ctx.kv.delete(SITE_SNAPSHOT_KEY); - } catch { - /* Never leak storage details to the admin response. */ - } - return { success: false, error: connectionError(error) }; - } - }, + handler: validateConnection, }, }, hooks: { diff --git a/src/settings/schema.ts b/src/settings/schema.ts index 98d9f27..e9e6267 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -21,4 +21,11 @@ export const settingsSchema = { description: "Load the OpenAnalytics tracking script on public pages.", default: true, }, + timezone: { + type: "string", + label: "Analytics timezone", + description: + "IANA timezone used for OpenAnalytics date ranges and freshness labels (for example, America/New_York).", + default: "UTC", + }, } satisfies Record; diff --git a/tests/admin.test.ts b/tests/admin.test.ts new file mode 100644 index 0000000..f87d1a7 --- /dev/null +++ b/tests/admin.test.ts @@ -0,0 +1,875 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import { createDialect as createSqliteDialect } from "emdash/db/sqlite"; +import { + EmDashRuntime, + dispatchPluginApiRequest, + handlePluginSettingsUpdate, + type RuntimeDependencies, +} from "emdash/internal/plugin-test-runtime"; +import { renderFragments } from "emdash/page"; +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 privateKey = "oa_sk_admin_test_private_key"; +const encryptionKey = `emdash_enc_v1_${Buffer.alloc(32, 12).toString("base64url")}`; +const apiUrl = "https://api.openanalytics.test"; +const site = { + site_id: "site_admin_test", + slug: "docs", + name: "Documentation", + status: "active", + install: { + tracking_key: "oa_pk_admin_public", + script_url: "https://cdn.openanalytics.test/tracker.js", + collector_url: "https://api.openanalytics.test/v1/collect", + }, +}; +const responseMeta = () => { + const to = new Date().toISOString(); + const from = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + return { + requested_range: { from, to }, + effective_range: { from, to }, + timezone: "America/New_York", + resolution: "day", + data_sources: ["live"], + accuracy: "exact", + freshness: { state: "ok", watermark: to, as_of: to }, + comparison_range: null, + truncated: false, + cached: false, + partial: false, + }; +}; + +async function makeRuntime() { + vi.stubEnv("EMDASH_ENCRYPTION_KEY", encryptionKey); + const directory = mkdtempSync(join(tmpdir(), "emdash-openanalytics-admin-test-")); + const runtime = await EmDashRuntime.create({ + config: { + database: { + entrypoint: `openanalytics-admin-test-${basename(directory)}`, + config: { url: `file:${join(directory, "test.sqlite")}` }, + type: "sqlite", + }, + }, + plugins: [createPlugin()], + createDialect: (config) => createSqliteDialect(config), + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + } as RuntimeDependencies); + runtimes.push({ runtime, directory }); + return runtime; +} + +async function setSettings(runtime: EmDashRuntime, values: Record) { + const plugin = runtime.configuredPlugins.find(({ id }) => id === pluginId); + if (!plugin?.admin?.settingsSchema) throw new Error("OpenAnalytics settings were not registered"); + const result = await handlePluginSettingsUpdate( + runtime.db, + pluginId, + plugin.admin.settingsSchema, + values, + ); + if (!result.success) throw new Error("Could not save OpenAnalytics test settings"); +} + +async function dispatchAdmin( + runtime: EmDashRuntime, + interaction: Record, + options: { role?: number; tokenScopes?: string[]; requestHeaders?: Record } = {}, +) { + const response = await dispatchPluginApiRequest({ + runtime, + pluginId, + path: "admin", + request: new Request(`https://cms.test/_emdash/api/plugins/${pluginId}/admin`, { + method: "POST", + headers: { "content-type": "application/json", ...options.requestHeaders }, + body: JSON.stringify(interaction), + }), + ...(options.role === undefined + ? {} + : { + user: { + id: `user-${options.role}`, + email: "admin@example.test", + name: "Admin test user", + role: options.role, + createdAt: new Date().toISOString(), + }, + }), + ...(options.tokenScopes === undefined ? {} : { tokenScopes: options.tokenScopes }), + }); + const payload = (await response.json()) as { success?: boolean; data?: unknown }; + return { + response, + payload, + data: payload.data as { blocks?: Array>; [key: string]: unknown }, + }; +} + +type SiteFailure = "network" | "timeout" | 401 | 429 | 500 | 503; + +function installFetch( + options: { + site?: unknown; + analyticsStatus?: number; + analyticsErrorCode?: string; + siteStatus?: number; + missingFreshness?: boolean; + freshnessState?: string; + } = {}, +) { + const requests: Array<{ url: string; init: RequestInit | undefined }> = []; + const siteFailures: SiteFailure[] = []; + let onFailedSiteFetch: (() => void) | undefined; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + const path = new URL(url).pathname; + if (path.endsWith("/v1/read/site")) { + const failure = siteFailures.shift(); + if (failure) { + onFailedSiteFetch?.(); + if (failure === "network") + throw new TypeError(`socket failed while handling ${privateKey}`); + if (failure === "timeout") { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + } + return new Response( + JSON.stringify({ error: { code: "UPSTREAM_ERROR" }, detail: privateKey }), + { + status: failure, + headers: { + "content-type": "application/json", + ...(failure === 429 ? { "retry-after": "21" } : {}), + }, + }, + ); + } + return new Response(JSON.stringify(options.site ?? site), { + status: options.siteStatus ?? 200, + headers: { "content-type": "application/json" }, + }); + } + if (options.analyticsStatus && options.analyticsStatus !== 200) { + return new Response( + JSON.stringify({ + error: { + code: + options.analyticsErrorCode ?? + (options.analyticsStatus === 403 ? "FORBIDDEN" : "UNAVAILABLE"), + }, + detail: privateKey, + }), + { + status: options.analyticsStatus, + headers: { + "content-type": "application/json", + ...(options.analyticsStatus === 429 ? { "retry-after": "21" } : {}), + }, + }, + ); + } + const parsedUrl = new URL(url); + const resolution = parsedUrl.searchParams.get("resolution"); + const timezone = parsedUrl.searchParams.get("timezone"); + if (path.endsWith("/overview") && resolution === "day" && timezone !== "UTC") { + return new Response(JSON.stringify({ error: { code: "RESOLUTION_NOT_AVAILABLE" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + const meta = responseMeta(); + if (options.freshnessState) meta.freshness.state = options.freshnessState; + meta.requested_range.from = parsedUrl.searchParams.get("from") ?? meta.requested_range.from; + meta.requested_range.to = parsedUrl.searchParams.get("to") ?? meta.requested_range.to; + meta.effective_range.from = meta.requested_range.from; + meta.effective_range.to = meta.requested_range.to; + meta.timezone = timezone ?? "UTC"; + meta.resolution = resolution ?? "day"; + if (options.missingFreshness) delete (meta as { freshness?: unknown }).freshness; + const comparisonRange = parsedUrl.searchParams.get("compare") === "true"; + const comparisonRangeMeta = comparisonRange + ? (() => { + const duration = + Date.parse(meta.requested_range.to) - Date.parse(meta.requested_range.from); + return { + from: new Date(Date.parse(meta.requested_range.from) - duration).toISOString(), + to: meta.requested_range.from, + }; + })() + : null; + const responseMetadata = { ...meta, comparison_range: comparisonRangeMeta }; + const body = path.endsWith("/overview") + ? { + meta: responseMetadata, + totals: { events: 1200, pageviews: 980, visitors: 380, billable_events: 1150 }, + comparison: comparisonRange + ? { totals: { events: 1100, pageviews: 900, visitors: 350, billable_events: 1000 } } + : null, + } + : { + meta: responseMetadata, + series: [{ bucket: meta.effective_range.from, events: 30, pageviews: 25, visitors: 20 }], + comparison: null, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + return { + fetchMock, + requests, + setSite(value: unknown) { + options.site = value; + }, + failNextSite(failure: SiteFailure, onFetch?: () => void) { + siteFailures.push(failure); + onFailedSiteFetch = onFetch; + }, + }; +} + +async function renderedTracking(runtime: EmDashRuntime) { + const result = await runtime.hooks.runPageFragments({ + page: { path: "/articles/example" }, + } as never); + return renderFragments( + result.flatMap(({ contributions }) => contributions), + "head", + ); +} + +async function validate( + runtime: EmDashRuntime, + options: { role?: number; tokenScopes?: string[] } = {}, +) { + const response = await dispatchPluginApiRequest({ + runtime, + pluginId, + path: "validate-connection", + request: new Request(`https://cms.test/_emdash/api/plugins/${pluginId}/validate-connection`, { + method: "POST", + }), + ...(options.role === undefined + ? {} + : { + user: { + id: `user-${options.role}`, + email: "admin@example.test", + name: "Admin", + role: options.role, + createdAt: new Date().toISOString(), + }, + }), + ...(options.tokenScopes === undefined ? {} : { tokenScopes: options.tokenScopes }), + } as never); + return response; +} + +afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + await Promise.all( + runtimes.splice(0).map(async ({ runtime, directory }) => { + await runtime.shutdown(); + rmSync(directory, { recursive: true, force: true }); + }), + ); +}); + +describe("OpenAnalytics native admin page", () => { + it.each([ + `https://api.openanalytics.test/${privateKey}`, + "https://api.openanalytics.test/%6fa%5fsk%5fadmin_test_private_key", + ])("never reflects credentials embedded in an invalid API URL", async (unsafeApiUrl) => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + await setSettings(runtime, { apiUrl: unsafeApiUrl, privateReadKey: privateKey }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + const output = JSON.stringify(result.data); + expect(output).toContain("Invalid API URL"); + expect(output).not.toContain(privateKey); + expect(output).not.toContain(unsafeApiUrl); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports an invalid analytics timezone without changing the working tracker", async () => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + timezone: "Mars/Olympus_Mons", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("Analytics timezone is invalid"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("preserves tracking after a rate limit on the standalone validation route", async () => { + const runtime = await makeRuntime(); + const { failNextSite } = installFetch(); + await setSettings(runtime, { apiUrl, privateReadKey: privateKey }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + failNextSite(429); + const response = await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + expect((await response.json()).data).toMatchObject({ + success: false, + error: { kind: "rate_limited" }, + }); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("requires the protected admin route and rejects CSRF and insufficient roles", async () => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + const unauthenticated = await dispatchAdmin(runtime, { type: "page_load", page: "/analytics" }); + expect(unauthenticated.response.status).toBe(401); + const denied = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 40, tokenScopes: ["admin"] }, + ); + expect(denied.response.status).toBe(403); + const csrf = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50 }, + ); + expect(csrf.response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("restricts the admin page to POST and bounds interaction bodies", async () => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + const options = { + runtime, + pluginId, + path: "admin", + user: { + id: "admin", + email: "admin@example.test", + name: "Admin", + role: 50, + createdAt: new Date().toISOString(), + }, + tokenScopes: ["admin"], + }; + const get = await dispatchPluginApiRequest({ + ...options, + request: new Request(`https://cms.test/_emdash/api/plugins/${pluginId}/admin`, { + method: "GET", + }), + }); + expect(get.status).toBe(405); + const oversized = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics", ignored: "x".repeat(5_000) }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(oversized.response.status).toBe(413); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("shows not-configured and configuration-changed states without making upstream calls", async () => { + const runtime = await makeRuntime(); + const { fetchMock, requests, failNextSite } = installFetch(); + const unconfigured = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(unconfigured.response.status).toBe(200); + expect(JSON.stringify(unconfigured.data)).toContain("Not configured"); + expect(fetchMock).not.toHaveBeenCalled(); + + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "America/New_York", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + await setSettings(runtime, { + apiUrl: "https://new-api.openanalytics.test", + trackingEnabled: true, + }); + const stale = 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); + failNextSite("network"); + const failedRevalidation = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(failedRevalidation.data)).not.toContain(privateKey); + const stillStale = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(stillStale.data)).toContain("Validate the connection"); + expect(requests.filter(({ url }) => url.includes("analytics/"))).toHaveLength(0); + expect(await renderedTracking(runtime)).toBe(""); + }); + + it("loads useful native blocks, emits explicit range and timezone, and never returns the private key", async () => { + const runtime = await makeRuntime(); + const { fetchMock, requests } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "America/New_York", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const loaded = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(loaded.response.status).toBe(200); + expect(loaded.data.blocks).toBeDefined(); + expect(validateBlockResponse(loaded.data, { pluginPagePaths: ["/analytics"] }).valid).toBe( + true, + ); + const output = JSON.stringify(loaded.data); + expect(output).toContain("Connected"); + expect(output).toContain("Tracking active"); + expect(output).toContain("Visitors"); + expect(output).toContain("Pageviews"); + expect(output).toContain("Latest rolled-up data"); + expect(output).toContain("Previous period: 350"); + expect(output).toContain("Previous period: 900"); + expect(output).not.toContain(privateKey); + expect(fetchMock).toHaveBeenCalledTimes(3); + for (const { url, init } of requests) { + expect(url).not.toContain(privateKey); + if (url.includes("analytics/")) { + const parsed = new URL(url); + expect(parsed.searchParams.get("from")).toBeTruthy(); + expect(parsed.searchParams.get("to")).toBeTruthy(); + expect(parsed.searchParams.get("timezone")).toBe("America/New_York"); + expect(parsed.searchParams.get("resolution")).toBe( + parsed.pathname.endsWith("/overview") ? "hour" : "day", + ); + expect(parsed.searchParams.get("compare")).toBe( + parsed.pathname.endsWith("/overview") ? "true" : null, + ); + expect(init?.headers).toMatchObject({ Authorization: `Bearer ${privateKey}` }); + } + } + const hourly = await dispatchAdmin( + runtime, + { type: "block_action", action_id: "range", value: "24h", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(hourly.response.status).toBe(200); + const analyticsRequests = requests.filter(({ url }) => url.includes("analytics/")); + const lastPair = analyticsRequests.slice(-2).map(({ url }) => new URL(url)); + const recentQuery = lastPair[0]!; + const from = Date.parse(recentQuery.searchParams.get("from")!); + const to = Date.parse(recentQuery.searchParams.get("to")!); + expect(to - from).toBeCloseTo(24 * 60 * 60 * 1000, -2); + expect(recentQuery.searchParams.get("resolution")).toBe("hour"); + expect(recentQuery.searchParams.get("timezone")).toBe("America/New_York"); + expect(lastPair[1]!.searchParams.get("from")).toBe(recentQuery.searchParams.get("from")); + expect(lastPair[1]!.searchParams.get("to")).toBe(recentQuery.searchParams.get("to")); + expect(recentQuery.searchParams.get("compare")).toBe("true"); + expect(lastPair[1]!.searchParams.has("compare")).toBe(false); + expect(JSON.stringify(hourly.data)).not.toContain(privateKey); + + for (const [preset, days] of [ + ["7d", 7], + ["30d", 30], + ["90d", 90], + ] as const) { + const selected = await dispatchAdmin( + runtime, + { type: "block_action", action_id: "range", value: preset, page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(selected.response.status).toBe(200); + const pair = requests + .filter(({ url }) => url.includes("analytics/")) + .slice(-2) + .map(({ url }) => new URL(url)); + const rangeMs = + Date.parse(pair[0]!.searchParams.get("to")!) - + Date.parse(pair[0]!.searchParams.get("from")!); + expect(rangeMs).toBeCloseTo(days * 24 * 60 * 60 * 1000, -2); + expect( + pair.find((item) => item.pathname.endsWith("/overview"))!.searchParams.get("resolution"), + ).toBe("hour"); + expect( + pair.find((item) => item.pathname.endsWith("/timeseries"))!.searchParams.get("resolution"), + ).toBe("day"); + expect(pair[0]!.searchParams.get("from")).toBe(pair[1]!.searchParams.get("from")); + expect(pair[0]!.searchParams.get("to")).toBe(pair[1]!.searchParams.get("to")); + expect( + pair.find((item) => item.pathname.endsWith("/overview"))!.searchParams.get("compare"), + ).toBe("true"); + expect( + pair.find((item) => item.pathname.endsWith("/timeseries"))!.searchParams.has("compare"), + ).toBe(false); + expect(JSON.stringify(selected.data)).toContain(`Last ${days} days`); + } + }); + + it("shows connected without a tracking key and handles missing freshness gracefully", async () => { + const runtime = await makeRuntime(); + const { requests } = installFetch({ + site: { ...site, install: { ...site.install, tracking_key: null } }, + missingFreshness: true, + }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("No tracking key"); + expect(requests.some(({ url }) => url.includes("analytics/"))).toBe(true); + }); + + it.each([ + [403, "This private key does not have analytics:read permission."], + [402, "OpenAnalytics paused this request because of a billing or service issue."], + [429, "OpenAnalytics rate limit reached. Try again in 21 seconds."], + [503, "OpenAnalytics is temporarily unavailable. Try again shortly."], + ] as const)("renders a safe analytics error state for HTTP %i", async (status, message) => { + const runtime = await makeRuntime(); + installFetch({ analyticsStatus: status }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain(message); + expect(JSON.stringify(result.data)).toContain("Tracking active"); + expect(JSON.stringify(result.data)).not.toContain(privateKey); + expect(JSON.stringify(result.data)).not.toContain("UNAVAILABLE"); + }); + + it("explains the suspended-site analytics gate", async () => { + const runtime = await makeRuntime(); + installFetch({ analyticsStatus: 403, analyticsErrorCode: "SITE_SUSPENDED" }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("This OpenAnalytics site is suspended"); + }); + + it.each([ + ["stale", "delayed"], + ["degraded", "status unavailable"], + ] as const)( + "shows a delayed-data notice when freshness is %s", + async (freshnessState, displayState) => { + const runtime = await makeRuntime(); + installFetch({ freshnessState }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("Results may be delayed or incomplete"); + expect(JSON.stringify(result.data)).toContain(`totals ${displayState}`); + }, + ); + + it("shows 'Not recorded' for a valid scaffold snapshot without validation time", async () => { + const runtime = await makeRuntime(); + installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const optionName = `plugin:${pluginId}:state:validated-site`; + const option = await runtime.db + .selectFrom("options") + .select("value") + .where("name", "=", optionName) + .executeTakeFirstOrThrow(); + const snapshot = JSON.parse(option.value) as Record; + delete snapshot.validatedAt; + await runtime.db + .updateTable("options") + .set({ value: JSON.stringify(snapshot) }) + .where("name", "=", optionName) + .execute(); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("Not recorded"); + }); + + it("uses EmDash authentication for revalidation, refreshes the public snapshot, and returns no secret", async () => { + const runtime = await makeRuntime(); + const { fetchMock } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + const unauthorized = await dispatchAdmin(runtime, { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }); + expect(unauthorized.response.status).toBe(401); + const denied = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 40, tokenScopes: ["admin"] }, + ); + expect(denied.response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + const validated = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(validated.response.status).toBe(200); + expect(JSON.stringify(validated.data)).toContain("Connected"); + expect(JSON.stringify(validated.data)).not.toContain(privateKey); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("keeps a matching validated snapshot through transient revalidation failures", async () => { + const runtime = await makeRuntime(); + const { fetchMock, failNextSite } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + + for (const failure of ["network", 429, 500, 503] as const) { + failNextSite(failure); + const result = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(result.response.status).toBe(200); + const text = JSON.stringify(result.data); + expect(text).toContain("Revalidation failed"); + expect(text).toContain("Tracking active"); + expect(text).not.toContain(privateKey); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + const page = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(page.data)).toContain("Tracking active"); + expect(page.response.status).toBe(200); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + } + expect(fetchMock.mock.calls.every(([input]) => !String(input).includes(privateKey))).toBe(true); + }); + + it("clears a revoked credential snapshot after a 401 revalidation", async () => { + const runtime = await makeRuntime(); + const { requests, failNextSite } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + failNextSite(401); + const failed = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(failed.data)).toContain("OpenAnalytics rejected this credential"); + expect(await renderedTracking(runtime)).toBe(""); + const before = requests.length; + const page = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(page.data)).toContain("Validate the connection"); + expect(requests).toHaveLength(before); + }); + + it("keeps the current snapshot after a timeout and preserves error context", async () => { + const runtime = await makeRuntime(); + const { failNextSite } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + + let enteredResolve!: () => void; + const entered = new Promise((resolve) => (enteredResolve = resolve)); + failNextSite("timeout", enteredResolve); + vi.useFakeTimers(); + const request = dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + await entered; + await vi.advanceTimersByTimeAsync(5_100); + const result = await request; + vi.useRealTimers(); + expect(JSON.stringify(result.data)).toContain("Revalidation failed"); + expect(JSON.stringify(result.data)).toContain("Tracking active"); + const page = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(page.response.status).toBe(200); + expect(JSON.stringify(page.data)).toContain("Tracking active"); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + + it("revalidation atomically replaces the validated public tracker metadata", async () => { + const runtime = await makeRuntime(); + const { setSite } = installFetch(); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + setSite({ + ...site, + name: "Documentation v2", + install: { ...site.install, tracking_key: "oa_pk_revalidated" }, + }); + const refreshed = await dispatchAdmin( + runtime, + { + type: "block_action", + action_id: "revalidate", + value: { range: "30d" }, + page: "/analytics", + }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(refreshed.response.status).toBe(200); + const html = await renderedTracking(runtime); + expect(html).toContain('data-key="oa_pk_revalidated"'); + expect(html).not.toContain('data-key="oa_pk_admin_public"'); + expect(html).not.toContain("oa_sk_"); + }); +}); diff --git a/tests/analytics-client.test.ts b/tests/analytics-client.test.ts new file mode 100644 index 0000000..3235ea7 --- /dev/null +++ b/tests/analytics-client.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getOverview, getTimeseries } from "../src/openanalytics/client"; +import { OpenAnalyticsError } from "../src/openanalytics/errors"; +import { parseConfiguration } from "../src/settings/config"; + +const apiUrl = "https://api.openanalytics.test"; +const readKey = "oa_sk_analytics_client_fixture_secret"; +const config = parseConfiguration({ apiUrl, readKey }); +const query = { + from: "2026-07-16T00:00:00.000Z", + to: "2026-07-23T00:00:00.000Z", + timezone: "America/New_York", + resolution: "hour" as const, +}; + +const meta = { + requested_range: { from: query.from, to: query.to }, + effective_range: { from: query.from, to: query.to }, + timezone: query.timezone, + resolution: query.resolution, + data_sources: ["live"], + accuracy: "exact", + freshness: { + state: "stale", + watermark: "2026-07-23T00:00:00.000Z", + as_of: "2026-07-23T09:00:00.000Z", + }, + comparison_range: null, + truncated: false, + cached: false, + partial: false, +}; + +const overview = { + meta, + totals: { events: 1200, pageviews: 980, visitors: 380, billable_events: 1150 }, + comparison: null, +}; + +const timeseries = { + meta, + series: [ + { + bucket: "2026-07-16T00:00:00.000Z", + events: 1200, + pageviews: 980, + visitors: 380, + }, + ], + comparison: null, +}; + +function mockFetch(status: number, body: unknown, headers: HeadersInit = {}) { + const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("OpenAnalytics analytics read client", () => { + it("reads the typed overview with explicit range, timezone, and resolution", async () => { + const fetchMock = mockFetch(200, overview); + + expect(await getOverview(config, query)).toEqual(overview); + expect(fetchMock).toHaveBeenCalledOnce(); + const [input, init] = fetchMock.mock.calls[0] ?? []; + const url = new URL(String(input)); + expect(url.origin + url.pathname).toBe( + "https://api.openanalytics.test/v1/read/analytics/overview", + ); + expect(url.searchParams.get("from")).toBe(query.from); + expect(url.searchParams.get("to")).toBe(query.to); + expect(url.searchParams.get("timezone")).toBe(query.timezone); + expect(url.searchParams.get("resolution")).toBe("hour"); + expect(url.searchParams.has("compare")).toBe(false); + expect(init).toMatchObject({ + method: "GET", + redirect: "error", + cache: "no-store", + headers: { Authorization: `Bearer ${readKey}`, Accept: "application/json" }, + }); + }); + + it("projects only documented fields and ignores additive response fields", async () => { + mockFetch(200, { ...overview, future: { credential: readKey } }); + const result = await getOverview(config, query); + expect(JSON.stringify(result)).not.toContain(readKey); + expect("future" in result).toBe(false); + }); + + it("requests and projects previous-period comparison only when explicitly enabled", async () => { + const comparison = { + from: "2026-07-09T00:00:00.000Z", + to: query.from, + }; + const compareResponse = { + ...overview, + meta: { ...meta, comparison_range: comparison }, + comparison: { + totals: { events: 1100, pageviews: 900, visitors: 350, billable_events: 1000 }, + }, + }; + const fetchMock = mockFetch(200, compareResponse); + const result = await getOverview(config, { ...query, compare: true }); + expect(new URL(String(fetchMock.mock.calls[0]?.[0])).searchParams.get("compare")).toBe("true"); + expect(result.meta.comparison_range).toEqual(comparison); + expect(result.comparison?.totals).toEqual(compareResponse.comparison.totals); + }); + + it("reads timeseries at the explicitly requested hour resolution", async () => { + const fetchMock = mockFetch(200, timeseries); + const hourly = { ...query, resolution: "hour" as const }; + + expect(await getTimeseries(config, hourly)).toEqual(timeseries); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe("/v1/read/analytics/timeseries"); + expect(url.searchParams.get("from")).toBe(query.from); + expect(url.searchParams.get("to")).toBe(query.to); + expect(url.searchParams.get("timezone")).toBe(query.timezone); + expect(url.searchParams.get("resolution")).toBe("hour"); + expect(url.searchParams.has("compare")).toBe(false); + }); + + it.each([ + ["overview", () => getOverview(config, query), overview, "totals"], + ["timeseries", () => getTimeseries(config, query), timeseries, "series"], + ] as const)( + "rejects malformed %s responses without returning upstream content", + async (_name, call, body, required) => { + const malformed = { ...body, [required]: undefined }; + mockFetch(200, malformed); + await expect(call()).rejects.toMatchObject({ kind: "invalid_response" }); + }, + ); + + it.each([ + { ...overview, meta: { ...meta, timezone: readKey } }, + { ...overview, meta: { ...meta, timezone: `America/New_York/${encodeURIComponent(readKey)}` } }, + ])("rejects known metadata carrying a raw or encoded private credential", async (body) => { + mockFetch(200, body); + await expect(getOverview(config, query)).rejects.toMatchObject({ kind: "invalid_response" }); + }); + + it("rejects malformed JSON and invalid freshness metadata", async () => { + mockFetch(200, "unexpected response"); + await expect(getOverview(config, query)).rejects.toMatchObject({ kind: "invalid_response" }); + + mockFetch(200, { + ...overview, + meta: { ...meta, freshness: { state: "future", as_of: "bad" } }, + }); + await expect(getOverview(config, query)).rejects.toMatchObject({ kind: "invalid_response" }); + }); + + it.each([ + { ...overview, totals: { ...overview.totals, visitors: -1 } }, + { ...overview, totals: { ...overview.totals, visitors: 1.5 } }, + { ...overview, totals: { events: 10, pageviews: 8, visitors: 3 } }, + ])("rejects invalid counts and incomplete required totals", async (body) => { + mockFetch(200, body); + await expect(getOverview(config, query)).rejects.toMatchObject({ kind: "invalid_response" }); + }); + + it.each([ + { ...timeseries, series: [{ ...timeseries.series[0], bucket: "not-a-date" }] }, + { ...timeseries, series: [{ ...timeseries.series[0], pageviews: -1 }] }, + { ...timeseries, series: [{ ...timeseries.series[0], visitors: 1.2 }] }, + ])("rejects invalid time buckets and series values", async (body) => { + mockFetch(200, body); + await expect(getTimeseries(config, query)).rejects.toMatchObject({ kind: "invalid_response" }); + }); + + it.each([ + { ...query, from: query.to }, + { ...query, from: "2026-02-30T00:00:00.000Z" }, + { ...query, timezone: "Mars/Olympus_Mons" }, + { ...query, resolution: "minute" as never }, + { ...query, resolution: { toString: () => "day" } as never }, + ])( + "rejects invalid range, timezone, or resolution before making a request", + async (invalidQuery) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(getOverview(config, invalidQuery)).rejects.toMatchObject({ + kind: "configuration", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, null])("accepts unavailable freshness metadata (%s)", async (freshness) => { + const response = { ...overview, meta: { ...meta, freshness } }; + mockFetch(200, response); + const result = await getOverview(config, query); + expect(result.meta.freshness ?? null).toBeNull(); + }); + + it.each([ + [401, {}, "unauthorized"], + [403, { error: { code: "FORBIDDEN" } }, "analytics_forbidden"], + [403, { error: { code: "SITE_SUSPENDED" } }, "suspended"], + [402, { error: { code: "BILLING_REQUIRED" } }, "billing"], + [400, { error: { code: "RESOLUTION_NOT_AVAILABLE" } }, "resolution_unavailable"], + [400, { error: { code: "VALIDATION_FAILED" } }, "range_invalid"], + [429, {}, "rate_limited"], + [503, {}, "server"], + ] as const)("normalizes analytics HTTP %i safely", async (status, payload, kind) => { + mockFetch(status, { ...payload, detail: readKey }, { "retry-after": "19" }); + try { + await getOverview(config, query); + throw new Error("expected analytics request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAnalyticsError); + expect(error).toMatchObject({ kind, status }); + expect((error as Error).message).not.toContain(readKey); + expect((error as Error).stack).not.toContain(readKey); + if (status === 429) expect(error).toMatchObject({ retryAfterSeconds: 19 }); + } + }); + + it.each([ + [403, { error: { code: "FORBIDDEN" } }, "analytics_forbidden"], + [429, {}, "rate_limited"], + [503, {}, "server"], + ] as const)("normalizes timeseries HTTP %i safely", async (status, payload, kind) => { + mockFetch(status, { ...payload, detail: readKey }, { "retry-after": "19" }); + await expect(getTimeseries(config, query)).rejects.toMatchObject({ kind, status }); + }); + + it("keeps response comparison null and does not invent or merge comparison data", async () => { + mockFetch(200, overview); + const result = await getOverview(config, query); + expect(result.comparison).toBeNull(); + expect(result.totals.visitors).toBe(380); + }); +}); diff --git a/tests/http.test.ts b/tests/http.test.ts index af3421b..56eacef 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getSite } from "../src/openanalytics/client"; +import { getOverview, getSite, getTimeseries } from "../src/openanalytics/client"; import { OpenAnalyticsError } from "../src/openanalytics/errors"; import { parseConfiguration } from "../src/settings/config"; @@ -19,6 +19,12 @@ const site = { collector_url: "https://events.example.test/collect", }, }; +const analyticsQuery = { + from: "2026-07-16T00:00:00.000Z", + to: "2026-07-23T00:00:00.000Z", + timezone: "America/New_York", + resolution: "hour" as const, +}; let server: Server; let baseUrl: string; @@ -44,7 +50,14 @@ async function route(request: IncomingMessage, response: ServerResponse) { return; } - if (request.url !== "/api-root/v1/read/site") { + const path = new URL(request.url ?? "/", "http://fixture.test").pathname; + if ( + ![ + "/api-root/v1/read/site", + "/api-root/v1/read/analytics/overview", + "/api-root/v1/read/analytics/timeseries", + ].includes(path) + ) { response.writeHead(404); response.end(); return; @@ -64,7 +77,36 @@ async function route(request: IncomingMessage, response: ServerResponse) { return; } - sendJson(response, site); + if (path === "/api-root/v1/read/site") { + sendJson(response, site); + return; + } + const url = new URL(request.url ?? "/", "http://fixture.test"); + const meta = { + requested_range: { from: url.searchParams.get("from"), to: url.searchParams.get("to") }, + effective_range: { from: url.searchParams.get("from"), to: url.searchParams.get("to") }, + timezone: url.searchParams.get("timezone"), + resolution: url.searchParams.get("resolution"), + data_sources: ["live"], + accuracy: "exact", + freshness: { state: "ok", watermark: analyticsQuery.to, as_of: analyticsQuery.to }, + comparison_range: null, + truncated: false, + cached: false, + partial: false, + }; + const body = path.endsWith("/overview") + ? { + meta, + totals: { events: 1200, pageviews: 980, visitors: 380, billable_events: 1150 }, + comparison: null, + } + : { + meta, + series: [{ bucket: analyticsQuery.from, events: 50, pageviews: 40, visitors: 20 }], + comparison: null, + }; + sendJson(response, body); } beforeAll(async () => { @@ -83,7 +125,7 @@ afterAll(async () => { await once(server, "close"); }); -describe("getSite HTTP transport", () => { +describe("OpenAnalytics HTTP transport", () => { it("requests the site below an API path prefix and preserves custom install URLs", async () => { slowResponse = false; const result = await getSite(parseConfiguration({ apiUrl: baseUrl, readKey })); @@ -119,4 +161,47 @@ describe("getSite HTTP transport", () => { slowResponse = false; } }); + + it("requests analytics reads below an API path prefix with explicit query params", async () => { + slowResponse = false; + const result = await getOverview( + parseConfiguration({ apiUrl: baseUrl, readKey }), + analyticsQuery, + ); + const requestUrl = new URL(requestPath, "http://fixture.test"); + expect(requestUrl.pathname).toBe("/api-root/v1/read/analytics/overview"); + expect(requestUrl.searchParams.get("from")).toBe(analyticsQuery.from); + expect(requestUrl.searchParams.get("to")).toBe(analyticsQuery.to); + expect(requestUrl.searchParams.get("timezone")).toBe(analyticsQuery.timezone); + expect(requestUrl.searchParams.get("resolution")).toBe("hour"); + expect(authorization).toBe(`Bearer ${readKey}`); + expect(result.totals.visitors).toBe(380); + }); + + it("rejects analytics redirects without forwarding the read key", async () => { + redirectedRequestCount = 0; + redirectToFixture = true; + try { + await expect( + getTimeseries(parseConfiguration({ apiUrl: baseUrl, readKey }), analyticsQuery), + ).rejects.toMatchObject({ kind: "network" }); + expect(redirectedRequestCount).toBe(0); + } finally { + redirectToFixture = false; + } + }); + + it("times out while an analytics response body is pending", async () => { + slowResponse = true; + try { + await expect( + getOverview( + parseConfiguration({ apiUrl: baseUrl, readKey, timeoutMs: 20 }), + analyticsQuery, + ), + ).rejects.toMatchObject({ kind: "timeout" }); + } finally { + slowResponse = false; + } + }); });