diff --git a/.github/readme/README.en.md b/.github/readme/README.en.md index e4651740..b7c1af89 100644 --- a/.github/readme/README.en.md +++ b/.github/readme/README.en.md @@ -142,6 +142,21 @@ After filling in the variables, wait about 3 minutes for the deployment to finis ## Advanced Configuration +### Connect AI Agents for Analysis + +InsightFlare exposes Skills for AI Agents. You can connect your InsightFlare deployment to agents such as OpenClaw, Codex, Claude Code, and others, so they can access InsightFlare data directly for analysis and report generation. +Send the following instruction to your Agent, replacing the domain with your deployed InsightFlare instance. Your Agent will guide you to the dashboard to create a dedicated API key for accessing InsightFlare data. + +```txt +Read https:///.well-known/skills.json, connect to this web analytics system, and guide me through authorization. +``` + +Then you can ask your Agent questions in natural language, for example: + +```txt +"How did my site perform last month? Where did most visitors come from among the highest-traffic sites? Which pages were the most popular?" +``` + ### Override Wrangler Configuration with Cloudflare Variables In Cloudflare build environments, you can use project variables and secrets to override deployment-specific values from `wrangler.toml`. `build:pre` reads these values before deployment, writes them into the active Wrangler config, and the following `wrangler deploy` uses the resolved config. @@ -295,7 +310,7 @@ Set `NEXT_PUBLIC_DEMO_MODE=1` to make the development server automatically enabl | Command | Purpose | | --------------------------------- | ------------------------------------------- | | `npm run dev` | Local dashboard development | -| `npm run check` | Run typecheck + lint + format + i18n checks | +| `npm run check` | Run typecheck + lint + format + i18n + tests + spec checks | | `npm run typecheck` | TypeScript type checking | | `npm run lint` / `lint:fix` | ESLint | | `npm run format` / `format:check` | Prettier | diff --git a/.github/readme/README.zh.md b/.github/readme/README.zh.md index e46b65b2..65d128bc 100644 --- a/.github/readme/README.zh.md +++ b/.github/readme/README.zh.md @@ -142,6 +142,21 @@ Cloudflare 会自动 Clone 这个仓库、创建并绑定所需要的资源。 ## 进阶配置 +### 接入 AI Agents 进行分析 + +我们开放了用于 AI Agents 的 Skills,您可以选择将 InsightFlare 接入您的 Agents,例如 OpenClaw、Codex、Claude Code 等, 让 Agents 能够直接访问 InsightFlare 的数据,进行分析和报告生成。 +请直接发送下面的指令给您的 Agent,您需要域名换成您部署的 InsightFlare 实例。您的 Agents 会指引您前往仪表盘为其创建一个专用 API 密钥,便于其访问 InsightFlare 的数据。 + +```txt +阅读 https://<您的 InsightFlare 域名>/.well-known/skills.json,接入这个访问分析系统,并指引我进行授权。 +``` + +随后,您可以以任意自然语言向 Agent 提问,例如: + +```txt +“上个月,我的站点的访问情况如何?访问量最高的站点中,访客大都是来自哪里的?哪些页面最受欢迎?” +``` + ### 使用 Cloudflare 变量覆盖 Wrangler 配置 在 Cloudflare 的构建环境中,可以通过「变量和密钥」覆盖 `wrangler.toml` 中需要因部署而变化的配置。`build:pre` 会在部署前读取这些变量并写入当前 Wrangler 配置,随后 `wrangler deploy` 会使用覆盖后的配置。 @@ -295,7 +310,7 @@ InsightFlare 的前端 SDK 支持以手动调用的方式上报自定义事件 | 命令 | 用途 | | --------------------------------- | ---------------------------------------------- | | `npm run dev` | 本地开发仪表板 | -| `npm run check` | 一键执行 typecheck + lint + format + i18n 校验 | +| `npm run check` | 一键执行 typecheck + lint + format + i18n + test + spec 校验 | | `npm run typecheck` | TypeScript 类型检查 | | `npm run lint` / `lint:fix` | ESLint | | `npm run format` / `format:check` | Prettier | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f18ac4e7..a72e214e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,18 @@ jobs: echo "file_path=$NEW_FILE" >> "$GITHUB_OUTPUT" echo "Detected release changelog: $NEW_FILE" - - name: Sync package versions + - name: Setup Node.js + if: steps.changelog.outputs.has_release == 'true' + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + if: steps.changelog.outputs.has_release == 'true' + run: npm ci + + - name: Sync release artifacts if: steps.changelog.outputs.has_release == 'true' shell: bash run: | @@ -83,16 +94,20 @@ jobs: updatePackageLock("package-lock.json"); NODE - git add package.json package-lock.json + npm run generate:openapi + npm run generate:skills + npx tsx scripts/check-openapi-contract.ts + + git add package.json package-lock.json docs/openapi.json docs/openapi.yaml docs/skills.json if git diff --cached --quiet; then - echo "Package versions are already up to date." + echo "Release artifacts are already up to date." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(release): sync package version to ${{ steps.changelog.outputs.version }} [skip ci]" + git commit -m "chore(release): sync release artifacts to ${{ steps.changelog.outputs.version }} [skip ci]" git push origin HEAD:${{ github.ref_name }} - name: Finalize release commit @@ -114,7 +129,7 @@ jobs: - name: Create release if: steps.changelog.outputs.has_release == 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.changelog.outputs.version }} name: Release ${{ steps.changelog.outputs.version }} diff --git a/.gitignore b/.gitignore index 9caf4268..5d30a955 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ src/tracker/sdk.min.ts src/tracker/sdk.no-perf.min.ts coverage logs/ - +plan/ diff --git a/changelog/v0.2.0.md b/changelog/v0.2.0.md new file mode 100644 index 00000000..8d09a4cd --- /dev/null +++ b/changelog/v0.2.0.md @@ -0,0 +1,35 @@ +InsightFlare v0.2.0 expands the product from a dashboard-first analytics app into a more integration-ready analytics service with a documented API surface, API key authentication, and stronger request validation. + +This release focuses on making InsightFlare easier to connect with external tools and safer to expose programmatically. It introduces API key management, a versioned API contract, OpenAPI and agent discovery endpoints, and a more consistent API response model. + +## Highlights + +- Added API key management, including scoped keys, API key authentication, and dashboard controls for creating and managing access. +- Introduced the versioned `/api/v1` API surface with generated OpenAPI 3.1 documentation and request/response examples. +- Added `.well-known` discovery endpoints for OpenAPI, agent skills, health, security, and account password-change metadata. +- Added generated `skills.json` documentation so LLM and agent clients can understand the public API more easily. +- Added stronger request validation through shared Zod schemas for analytics, realtime, site, team, funnel, tracker, and common API inputs. +- Improved API response consistency with request IDs, timestamps, and a unified response envelope. +- Added same-origin request validation and strengthened secret/session handling for safer deployments. +- Added dashboard surfaces for API key management and clearer version update details. +- Improved geography and realtime dashboard rendering by moving heavier map and realtime views into dedicated client-side stages. + +## Changes Since v0.1.0 + +- Fixed API version reporting so it is read from `package.json` instead of being hardcoded. +- Fixed `/api/v1` root route matching with an optional catch-all route. +- Aligned generated OpenAPI metadata, schemas, examples, and endpoint behavior with the actual implementation. +- Fixed mobile page scrolling behavior. +- Fixed rollback secret handling so fallback values are applied correctly. +- Replaced hardcoded locale checks with the i18n system. +- Renamed the funnel route to `funnels` for consistency. +- Removed unnecessary release-summary height restrictions. +- Reduced worker bundle pressure by moving heavier dashboard experiences behind client-only islands. + +## Developer Experience + +- Added scripts for generating OpenAPI and skills documentation. +- Added an OpenAPI contract checker to catch documentation drift. +- Added CI coverage thresholds and broadened test coverage across API v1, API keys, scheduled tasks, hourly rollups, realtime mocks, schemas, validation, and dashboard client data. +- Added explicit Prettier defaults and stricter ESLint coverage for the codebase. +- Added environment and prebuild checks for safer Cloudflare deployment flows. diff --git a/docs/openapi.json b/docs/openapi.json index fb039558..c380a114 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "InsightFlare API", - "description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All API times are ISO 8601 strings and analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.", - "version": "1.0.0", + "description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.", + "version": "0.2.0", "contact": { "name": "InsightFlare", "url": "https://github.com/ravelloh/InsightFlare" @@ -13,6 +13,11 @@ "url": "https://github.com/ravelloh/InsightFlare/blob/main/LICENSE" } }, + "externalDocs": { + "description": "InsightFlare API documentation", + "url": "https://insight.ravelloh.com/docs" + }, + "x-possible-upstream-responses": [429], "servers": [ { "url": "https://insight.ravelloh.com", @@ -120,7 +125,8 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "x-required-scopes": [] } }, "/collect": { @@ -202,7 +208,8 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" } - } + }, + "x-required-scopes": [] } }, "/api/v1": { @@ -225,7 +232,7 @@ "summary": "getApiRoot", "value": { "data": { - "version": "1.0.0", + "version": "0.2.0", "service": "InsightFlare Analytics API", "links": { "self": "/api/v1", @@ -252,7 +259,8 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "x-required-scopes": [] } }, "/api/v1/token": { @@ -307,7 +315,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": [] } }, "/api/v1/token/check": { @@ -376,7 +385,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": [] } }, "/api/v1/capabilities": { @@ -398,7 +408,7 @@ "summary": "getCapabilities", "value": { "data": { - "apiVersion": "1.0.0", + "apiVersion": "0.2.0", "features": { "sites": true, "tracking": true, @@ -440,7 +450,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": [] } }, "/api/v1/team": { @@ -484,7 +495,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": ["site:read"] } }, "/api/v1/team/usage": { @@ -521,7 +533,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": ["site:read"] } }, "/api/v1/team/analytics/overview": { @@ -532,73 +545,22 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" } ], "responses": { @@ -647,7 +609,8 @@ "403": { "$ref": "#/components/responses/Forbidden" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/team/analytics/timeseries": { @@ -658,83 +621,25 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" } ], "responses": { @@ -784,7 +689,8 @@ "403": { "$ref": "#/components/responses/Forbidden" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/team/analytics/sites": { @@ -795,63 +701,19 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" } ], "responses": { @@ -906,13 +768,14 @@ "403": { "$ref": "#/components/responses/Forbidden" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/team/analytics/breakdowns/{dimension}": { "parameters": [ { - "$ref": "#/components/parameters/dimension" + "$ref": "#/components/parameters/DimensionPathParam" } ], "get": { @@ -922,73 +785,22 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" }, { "name": "limit", @@ -1051,7 +863,8 @@ "403": { "$ref": "#/components/responses/Forbidden" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites": { @@ -1113,7 +926,8 @@ "403": { "$ref": "#/components/responses/Forbidden" } - } + }, + "x-required-scopes": ["site:read"] }, "post": { "operationId": "createSite", @@ -1144,10 +958,8 @@ "value": { "name": "Example Blog", "domain": "example.com", - "sharing": { - "publicEnabled": true, - "publicSlug": "example-blog" - } + "publicEnabled": true, + "publicSlug": "example-blog" } } } @@ -1211,13 +1023,14 @@ "409": { "$ref": "#/components/responses/Conflict" } - } + }, + "x-required-scopes": ["site:write"] } }, "/api/v1/sites/{siteId}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1279,7 +1092,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:read"] }, "patch": { "operationId": "updateSite", @@ -1298,10 +1112,7 @@ "summary": "Update a site", "value": { "name": "Example Blog", - "sharing": { - "publicEnabled": false, - "publicSlug": null - } + "publicEnabled": false } } } @@ -1368,7 +1179,8 @@ "409": { "$ref": "#/components/responses/Conflict" } - } + }, + "x-required-scopes": ["site:write"] }, "delete": { "operationId": "deleteSite", @@ -1388,13 +1200,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:write"] } }, "/api/v1/sites/{siteId}/tracking": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1445,7 +1258,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updateTrackingSettings", @@ -1521,13 +1335,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:write"] } }, "/api/v1/sites/{siteId}/tracking/script": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1571,13 +1386,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] } }, "/api/v1/sites/{siteId}/privacy": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1623,7 +1439,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updatePrivacySettings", @@ -1690,13 +1507,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:write"] } }, "/api/v1/sites/{siteId}/sharing": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1739,7 +1557,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updateSharingSettings", @@ -1806,13 +1625,14 @@ "409": { "$ref": "#/components/responses/Conflict" } - } + }, + "x-required-scopes": ["site_config:write"] } }, "/api/v1/sites/{siteId}/analytics/schema": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1835,28 +1655,48 @@ "data": { "metrics": [ { + "id": "views", "key": "views", "label": "Views", "type": "integer", - "description": "Total page views." + "description": "Total page views.", + "unit": "count", + "aggregation": "sum", + "filterable": false, + "sortable": true }, { + "id": "bounceRate", "key": "bounceRate", "label": "Bounce rate", "type": "rate", - "description": "Single-page session rate as a 0-1 ratio." + "description": "Single-page session rate as a 0-1 ratio.", + "unit": "ratio", + "aggregation": "ratio", + "filterable": false, + "sortable": true } ], "dimensions": [ { + "id": "page.path", "key": "page.path", "label": "Page path", - "type": "string" + "description": "Normalized page path from the tracked URL.", + "type": "string", + "filterable": true, + "groupable": true, + "sortable": true }, { + "id": "geo.country", "key": "geo.country", "label": "Country", - "type": "string" + "description": "Visitor country inferred from request metadata.", + "type": "string", + "filterable": true, + "groupable": true, + "sortable": true } ], "filters": ["page.path", "geo.country"], @@ -1891,13 +1731,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/overview": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -1907,73 +1748,22 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" } ], "responses": { @@ -2025,13 +1815,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/timeseries": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -2041,83 +1832,25 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" } ], "responses": { @@ -2170,16 +1903,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/breakdowns/{dimension}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/dimension" + "$ref": "#/components/parameters/DimensionPathParam" } ], "get": { @@ -2189,73 +1923,22 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "metrics", - "in": "query", - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events" - ] - } - }, - "description": "Comma-separated metrics to include." + "$ref": "#/components/parameters/MetricsQueryParam" }, { "name": "limit", @@ -2322,66 +2005,36 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/cross-breakdowns": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { "operationId": "getAnalyticsCrossBreakdown", "summary": "Get analytics cross breakdown", - "description": "Returns a two-dimensional analytics breakdown.", + "description": "Returns a two-dimensional analytics breakdown. Supports page, referrer, UTM, client, and geo dimensions. Session and event dimensions are not supported.", "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { "name": "primary", @@ -2390,7 +2043,7 @@ "type": "string", "maxLength": 120 }, - "description": "Primary dimension." + "description": "Primary dimension (e.g. client.browser, geo.country, page.path)." }, { "name": "secondary", @@ -2399,7 +2052,7 @@ "type": "string", "maxLength": 120 }, - "description": "Secondary dimension." + "description": "Secondary dimension (must differ from primary)." }, { "name": "metric", @@ -2466,13 +2119,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/compare": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -2482,50 +2136,19 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { "name": "compare", @@ -2598,13 +2221,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/explore": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "post": { @@ -2706,13 +2330,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/analytics/retention/cohorts": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -2722,60 +2347,22 @@ "tags": ["Analytics"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" } ], "responses": { @@ -2833,13 +2420,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/event-types": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -2849,40 +2437,16 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { "name": "limit", @@ -2950,16 +2514,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/event-types/{eventName}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/eventName" + "$ref": "#/components/parameters/EventNamePathParam" } ], "get": { @@ -2969,50 +2534,19 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" } ], "responses": { @@ -3075,13 +2609,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/events": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -3091,70 +2626,25 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" }, { "name": "sort", @@ -3227,13 +2717,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/events/summary": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -3243,50 +2734,19 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" } ], "responses": { @@ -3330,13 +2790,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/events/timeseries": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -3346,60 +2807,22 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { "name": "eventName", @@ -3461,13 +2884,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/events/search": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "post": { @@ -3565,16 +2989,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/events/{eventId}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/eventId" + "$ref": "#/components/parameters/EventIdPathParam" } ], "get": { @@ -3624,13 +3049,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/event-fields/values": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -3640,40 +3066,16 @@ "tags": ["Events"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { "name": "eventName", @@ -3693,6 +3095,15 @@ }, "description": "Field path." }, + { + "name": "fieldValueType", + "in": "query", + "schema": { + "type": "string", + "enum": ["string", "number", "boolean", "null", "object", "array"] + }, + "description": "Expected value type for the field." + }, { "name": "search", "in": "query", @@ -3756,13 +3167,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/visitors": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -3772,70 +3184,25 @@ "tags": ["Visitors"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" }, { "name": "sort", @@ -3910,16 +3277,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/visitors/{visitorId}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/visitorId" + "$ref": "#/components/parameters/VisitorIdPathParam" } ], "get": { @@ -3971,16 +3339,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/visitors/{visitorId}/sessions": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/visitorId" + "$ref": "#/components/parameters/VisitorIdPathParam" } ], "get": { @@ -3990,60 +3359,22 @@ "tags": ["Visitors"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" } ], "responses": { @@ -4099,16 +3430,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/visitors/{visitorId}/events": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/visitorId" + "$ref": "#/components/parameters/VisitorIdPathParam" } ], "get": { @@ -4118,60 +3450,22 @@ "tags": ["Visitors"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" } ], "responses": { @@ -4226,13 +3520,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/sessions": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -4242,70 +3537,25 @@ "tags": ["Sessions"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" }, { "name": "sort", @@ -4379,16 +3629,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/sessions/{sessionId}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/sessionId" + "$ref": "#/components/parameters/SessionIdPathParam" } ], "get": { @@ -4439,16 +3690,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/sessions/{sessionId}/events": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/sessionId" + "$ref": "#/components/parameters/SessionIdPathParam" } ], "get": { @@ -4458,60 +3710,22 @@ "tags": ["Sessions"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - }, - "description": "Maximum number of results." + "$ref": "#/components/parameters/LimitQueryParam" }, { - "name": "cursor", - "in": "query", - "schema": { - "type": "string", - "maxLength": 512 - }, - "description": "Opaque pagination cursor from the previous response." + "$ref": "#/components/parameters/CursorQueryParam" } ], "responses": { @@ -4566,13 +3780,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/funnels": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -4582,40 +3797,16 @@ "tags": ["Funnels"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." - }, - { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." - }, - { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/ToQueryParam" + }, + { + "$ref": "#/components/parameters/PresetQueryParam" + }, + { + "$ref": "#/components/parameters/TimeZoneQueryParam" } ], "responses": { @@ -4678,7 +3869,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] }, "post": { "operationId": "createFunnel", @@ -4785,13 +3977,26 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:write"] } }, "/api/v1/sites/{siteId}/funnels/analysis": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" + }, + { + "$ref": "#/components/parameters/FromQueryParam" + }, + { + "$ref": "#/components/parameters/ToQueryParam" + }, + { + "$ref": "#/components/parameters/PresetQueryParam" + }, + { + "$ref": "#/components/parameters/TimeZoneQueryParam" } ], "post": { @@ -4810,11 +4015,6 @@ "default": { "summary": "Analyze an ad-hoc funnel", "value": { - "timeRange": { - "from": "2026-05-27T00:00:00Z", - "to": "2026-06-26T00:00:00Z", - "timeZone": "Asia/Shanghai" - }, "steps": [ { "type": "pageview", @@ -4826,13 +4026,6 @@ "value": "signup", "label": "Signup" } - ], - "filters": [ - { - "field": "geo.country", - "op": "in", - "value": ["US", "CA"] - } ] } } @@ -4913,16 +4106,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/funnels/{funnelId}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/funnelId" + "$ref": "#/components/parameters/FunnelIdPathParam" } ], "get": { @@ -4985,7 +4179,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] }, "patch": { "operationId": "updateFunnel", @@ -5080,7 +4275,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:write"] }, "delete": { "operationId": "deleteFunnel", @@ -5100,16 +4296,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:write"] } }, "/api/v1/sites/{siteId}/funnels/{funnelId}/analysis": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/funnelId" + "$ref": "#/components/parameters/FunnelIdPathParam" } ], "get": { @@ -5119,40 +4316,16 @@ "tags": ["Funnels"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" } ], "responses": { @@ -5254,13 +4427,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/performance/summary": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5270,50 +4444,19 @@ "tags": ["Performance"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" } ], "responses": { @@ -5357,13 +4500,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/performance/timeseries": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5373,60 +4517,22 @@ "tags": ["Performance"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "interval", - "in": "query", - "schema": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month"], - "default": "day" - }, - "description": "Time bucket granularity." + "$ref": "#/components/parameters/IntervalQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" } ], "responses": { @@ -5480,16 +4586,17 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/performance/breakdowns/{dimension}": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" }, { - "$ref": "#/components/parameters/dimension" + "$ref": "#/components/parameters/DimensionPathParam" } ], "get": { @@ -5499,50 +4606,19 @@ "tags": ["Performance"], "parameters": [ { - "name": "from", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/FromQueryParam" }, { - "name": "to", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - }, - "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/ToQueryParam" }, { - "name": "preset", - "in": "query", - "schema": { - "$ref": "#/components/schemas/Preset" - }, - "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + "$ref": "#/components/parameters/PresetQueryParam" }, { - "name": "timeZone", - "in": "query", - "schema": { - "type": "string", - "maxLength": 80, - "default": "UTC" - }, - "description": "IANA time zone used to resolve presets. Defaults to UTC." + "$ref": "#/components/parameters/TimeZoneQueryParam" }, { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "$ref": "#/components/schemas/FilterObject" - }, - "description": "Simple equality filters as filter[field]=value." + "$ref": "#/components/parameters/FilterQueryParam" }, { "name": "metric", @@ -5596,13 +4672,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/realtime/active-visitors": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5644,13 +4721,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/realtime/events": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5717,13 +4795,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/realtime/sessions": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5791,13 +4870,14 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/sites/{siteId}/realtime/snapshot": { "parameters": [ { - "$ref": "#/components/parameters/siteId" + "$ref": "#/components/parameters/SiteIdPathParam" } ], "get": { @@ -5866,7 +4946,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/batch": { @@ -5985,7 +5066,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": ["analytics:read"] } } }, @@ -6236,7 +5318,7 @@ }, "ComplexFilter": { "type": "object", - "description": "Advanced filter rule for explore and search endpoints. Operators: eq equals; neq does not equal; in is one of; notIn is not one of; contains includes substring; startsWith/endsWith match string edges; gt/gte/lt/lte compare ordered values; exists/notExists ignore value.", + "description": "Advanced filter rule for explore and search endpoints. For eq, neq, contains, startsWith, and endsWith, use a scalar value. For in and notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO 8601 date-time string depending on the field. For exists and notExists, value may be omitted.", "required": ["field", "op"], "properties": { "field": { @@ -6263,45 +5345,102 @@ "notExists" ] }, - "value": {} + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + ] + } } }, "MetricDefinition": { "type": "object", "description": "Metric available for analytics queries.", - "required": ["key", "label", "type", "description"], + "required": ["id", "key", "label", "type", "description"], "properties": { + "id": { + "type": "string" + }, "key": { "type": "string" }, "label": { "type": "string" }, + "description": { + "type": "string" + }, + "unit": { + "type": "string", + "enum": ["count", "ratio", "milliseconds"] + }, "type": { "type": "string", - "enum": ["integer", "rate", "duration_ms"] + "enum": ["integer", "number", "rate"] }, - "description": { - "type": "string" + "aggregation": { + "type": "string", + "enum": ["sum", "average", "ratio", "derived"] + }, + "filterable": { + "type": "boolean" + }, + "sortable": { + "type": "boolean" } } }, "DimensionDefinition": { "type": "object", "description": "Dimension available for analytics breakdowns and filters.", - "required": ["key", "label", "type"], + "required": ["id", "key", "label", "type"], "properties": { + "id": { + "type": "string" + }, "key": { "type": "string" }, "label": { "type": "string" }, - "type": { + "description": { "type": "string" }, - "description": { + "type": { "type": "string" + }, + "filterable": { + "type": "boolean" + }, + "groupable": { + "type": "boolean" + }, + "sortable": { + "type": "boolean" } } }, @@ -6727,14 +5866,22 @@ "minLength": 1, "maxLength": 255 }, - "sharing": { - "$ref": "#/components/schemas/SharingSettings" + "publicEnabled": { + "type": "boolean", + "default": false, + "description": "Whether the public sharing link is enabled." + }, + "publicSlug": { + "type": "string", + "maxLength": 120, + "description": "Optional public sharing slug when publicEnabled is true." } - } + }, + "additionalProperties": false }, "SiteUpdateInput": { "type": "object", - "description": "Partial update for site metadata and sharing settings.", + "description": "Partial update for site metadata and public sharing input fields.", "properties": { "name": { "type": "string", @@ -6746,10 +5893,17 @@ "minLength": 1, "maxLength": 255 }, - "sharing": { - "$ref": "#/components/schemas/SharingSettings" + "publicEnabled": { + "type": "boolean", + "description": "Whether the public sharing link is enabled." + }, + "publicSlug": { + "type": "string", + "maxLength": 120, + "description": "Optional public sharing slug when publicEnabled is true." } - } + }, + "additionalProperties": false }, "SiteResponse": { "description": "Response envelope.", @@ -6896,7 +6050,8 @@ }, "visitorTokenMode": { "type": "string", - "enum": ["daily"] + "enum": ["daily", "weekly", "monthly", "session", "none"], + "description": "Visitor token rotation mode. The current runtime behavior uses daily tokens; additional values are reserved for compatible future configuration." }, "dataRetentionDays": { "type": "integer", @@ -7672,7 +6827,34 @@ ] }, "value": { - "description": "Comparison value. Required unless op is exists or notExists." + "description": "Comparison value. Required unless op is exists or notExists.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + ] } }, "additionalProperties": false @@ -8080,12 +7262,9 @@ }, "FunnelAnalysisRequest": { "type": "object", - "description": "Request for ad-hoc funnel analysis.", + "description": "Request for ad-hoc funnel analysis. Use query parameters (from, to, preset, timeZone) for time range.", "required": ["steps"], "properties": { - "timeRange": { - "$ref": "#/components/schemas/TimeRangeInput" - }, "steps": { "type": "array", "minItems": 2, @@ -8093,12 +7272,6 @@ "items": { "$ref": "#/components/schemas/FunnelStepInput" } - }, - "filters": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ComplexFilter" - } } }, "additionalProperties": false @@ -8709,7 +7882,7 @@ } }, "parameters": { - "siteId": { + "SiteIdPathParam": { "name": "siteId", "in": "path", "required": true, @@ -8719,7 +7892,7 @@ }, "description": "Site UUID." }, - "dimension": { + "DimensionPathParam": { "name": "dimension", "in": "path", "required": true, @@ -8729,7 +7902,7 @@ }, "description": "Stable analytics dimension key." }, - "eventName": { + "EventNamePathParam": { "name": "eventName", "in": "path", "required": true, @@ -8739,7 +7912,7 @@ }, "description": "Event name." }, - "eventId": { + "EventIdPathParam": { "name": "eventId", "in": "path", "required": true, @@ -8749,27 +7922,27 @@ }, "description": "Event UUID." }, - "visitorId": { + "VisitorIdPathParam": { "name": "visitorId", "in": "path", "required": true, "schema": { "type": "string", - "format": "uuid" + "maxLength": 160 }, - "description": "Visitor UUID." + "description": "Opaque visitor identifier." }, - "sessionId": { + "SessionIdPathParam": { "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string", - "format": "uuid" + "maxLength": 160 }, - "description": "Session UUID." + "description": "Opaque session identifier." }, - "funnelId": { + "FunnelIdPathParam": { "name": "funnelId", "in": "path", "required": true, @@ -8778,6 +7951,105 @@ "format": "uuid" }, "description": "Funnel UUID." + }, + "FromQueryParam": { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + }, + "ToQueryParam": { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + }, + "PresetQueryParam": { + "name": "preset", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Preset" + }, + "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time." + }, + "TimeZoneQueryParam": { + "name": "timeZone", + "in": "query", + "schema": { + "type": "string", + "maxLength": 80, + "default": "UTC" + }, + "description": "IANA time zone used to resolve presets. Defaults to UTC." + }, + "IntervalQueryParam": { + "name": "interval", + "in": "query", + "schema": { + "type": "string", + "enum": ["minute", "hour", "day", "week", "month"], + "default": "day" + }, + "description": "Time bucket granularity." + }, + "MetricsQueryParam": { + "name": "metrics", + "in": "query", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "views", + "sessions", + "visitors", + "bounces", + "bounceRate", + "avgDurationMs", + "viewsPerSession", + "events" + ] + } + }, + "description": "Comma-separated metrics to include." + }, + "FilterQueryParam": { + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "$ref": "#/components/schemas/FilterObject" + }, + "description": "Simple equality filters as filter[field]=value." + }, + "LimitQueryParam": { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + }, + "description": "Maximum number of results." + }, + "CursorQueryParam": { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "maxLength": 512 + }, + "description": "Opaque pagination cursor from the previous response." } }, "responses": { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c2da3cf7..67fc42b0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,19 +1,35 @@ openapi: 3.1.0 info: title: InsightFlare API - description: - Privacy-focused web analytics API. Authenticated endpoints require - an API key passed as a Bearer token in the Authorization header. All API - times are ISO 8601 strings and analytics ranges use [from, to) semantics. If - from, to, and preset are omitted, analytics endpoints default to the last 7 - days ending at request time. The default timeZone is UTC. - version: 1.0.0 + description: >- + Privacy-focused web analytics API. Authenticated endpoints require an API + key passed as a Bearer token in the Authorization header. All timestamps in + query parameters and response objects are ISO 8601 date-time strings unless + the field name explicitly ends with `Ms`. Fields ending with `Ms` represent + millisecond values, such as durations or Unix timestamps depending on + context. Analytics ranges use [from, to) semantics. If from, to, and preset + are omitted, analytics endpoints default to the last 7 days ending at + request time. The default timeZone is UTC. + + + This OpenAPI document describes the behavior of the InsightFlare origin API. + Depending on deployment configuration, upstream infrastructure, proxies, + gateways, or edge providers may return additional HTTP responses before + requests reach the API origin, such as 429 Too Many Requests. These + responses are outside the standard API error envelope and are not part of + the stable API contract. + version: 0.2.0 contact: name: InsightFlare url: https://github.com/ravelloh/InsightFlare license: name: MIT url: https://github.com/ravelloh/InsightFlare/blob/main/LICENSE +externalDocs: + description: InsightFlare API documentation + url: https://insight.ravelloh.com/docs +x-possible-upstream-responses: + - 429 servers: - url: https://insight.ravelloh.com description: Production @@ -76,6 +92,7 @@ paths: $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" + x-required-scopes: [] /collect: post: operationId: collectEvent @@ -139,6 +156,7 @@ paths: $ref: "#/components/responses/BadRequest" "413": $ref: "#/components/responses/PayloadTooLarge" + x-required-scopes: [] /api/v1: get: operationId: getApiRoot @@ -161,7 +179,7 @@ paths: summary: getApiRoot value: data: - version: 1.0.0 + version: 0.2.0 service: InsightFlare Analytics API links: self: /api/v1 @@ -177,6 +195,7 @@ paths: $ref: "#/components/responses/BadRequest" "500": $ref: "#/components/responses/InternalError" + x-required-scopes: [] /api/v1/token: get: operationId: getToken @@ -219,6 +238,7 @@ paths: generatedAt: 2026-06-26T12:00:00Z "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: [] /api/v1/token/check: post: operationId: checkToken @@ -264,6 +284,7 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: [] /api/v1/capabilities: get: operationId: getCapabilities @@ -283,7 +304,7 @@ paths: summary: getCapabilities value: data: - apiVersion: 1.0.0 + apiVersion: 0.2.0 features: sites: true tracking: true @@ -313,6 +334,7 @@ paths: generatedAt: 2026-06-26T12:00:00Z "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: [] /api/v1/team: get: operationId: getTeam @@ -344,6 +366,8 @@ paths: generatedAt: 2026-06-26T12:00:00Z "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: + - site:read /api/v1/team/usage: get: operationId: getTeamUsage @@ -369,6 +393,8 @@ paths: generatedAt: 2026-06-26T12:00:00Z "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: + - site:read /api/v1/team/analytics/overview: get: operationId: getTeamAnalyticsOverview @@ -377,64 +403,12 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" responses: "200": description: Successful response @@ -468,6 +442,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + x-required-scopes: + - analytics:read /api/v1/team/analytics/timeseries: get: operationId: getTeamAnalyticsTimeseries @@ -476,76 +452,13 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" responses: "200": description: Successful response @@ -575,6 +488,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + x-required-scopes: + - analytics:read /api/v1/team/analytics/sites: get: operationId: getTeamAnalyticsSites @@ -583,57 +498,11 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" responses: "200": description: Successful response @@ -666,10 +535,12 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + x-required-scopes: + - analytics:read /api/v1/team/analytics/breakdowns/{dimension}: parameters: - &a5 - $ref: "#/components/parameters/dimension" + $ref: "#/components/parameters/DimensionPathParam" get: operationId: getTeamAnalyticsBreakdown summary: Get team analytics breakdown @@ -677,64 +548,12 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" - name: limit in: query schema: @@ -772,6 +591,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + x-required-scopes: + - analytics:read /api/v1/sites: get: operationId: listSites @@ -818,6 +639,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + x-required-scopes: + - site:read post: operationId: createSite summary: Create site @@ -843,9 +666,8 @@ paths: value: name: Example Blog domain: example.com - sharing: - publicEnabled: true - publicSlug: example-blog + publicEnabled: true + publicSlug: example-blog responses: "201": description: Created site @@ -869,10 +691,12 @@ paths: $ref: "#/components/responses/Forbidden" "409": $ref: "#/components/responses/Conflict" + x-required-scopes: + - site:write /api/v1/sites/{siteId}: parameters: - &a3 - $ref: "#/components/parameters/siteId" + $ref: "#/components/parameters/SiteIdPathParam" get: operationId: getSite summary: Get site @@ -900,6 +724,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:read patch: operationId: updateSite summary: Update site @@ -917,9 +743,7 @@ paths: summary: Update a site value: name: Example Blog - sharing: - publicEnabled: false - publicSlug: null + publicEnabled: false responses: "200": description: Successful response @@ -945,6 +769,8 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" + x-required-scopes: + - site:write delete: operationId: deleteSite summary: Delete site @@ -960,6 +786,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write /api/v1/sites/{siteId}/tracking: parameters: - *a3 @@ -1002,6 +830,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:read patch: operationId: updateTrackingSettings summary: Update tracking settings @@ -1061,6 +891,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:write /api/v1/sites/{siteId}/tracking/script: parameters: - *a3 @@ -1095,6 +927,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:read /api/v1/sites/{siteId}/privacy: parameters: - *a3 @@ -1130,6 +964,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:read patch: operationId: updatePrivacySettings summary: Update privacy settings @@ -1176,6 +1012,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:write /api/v1/sites/{siteId}/sharing: parameters: - *a3 @@ -1208,6 +1046,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site_config:read patch: operationId: updateSharingSettings summary: Update sharing settings @@ -1253,6 +1093,8 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" + x-required-scopes: + - site_config:write /api/v1/sites/{siteId}/analytics/schema: parameters: - *a3 @@ -1275,21 +1117,41 @@ paths: value: data: metrics: - - key: views + - id: views + key: views label: Views type: integer description: Total page views. - - key: bounceRate + unit: count + aggregation: sum + filterable: false + sortable: true + - id: bounceRate + key: bounceRate label: Bounce rate type: rate description: Single-page session rate as a 0-1 ratio. + unit: ratio + aggregation: ratio + filterable: false + sortable: true dimensions: - - key: page.path + - id: page.path + key: page.path label: Page path + description: Normalized page path from the tracked URL. type: string - - key: geo.country + filterable: true + groupable: true + sortable: true + - id: geo.country + key: geo.country label: Country + description: Visitor country inferred from request metadata. type: string + filterable: true + groupable: true + sortable: true filters: - page.path - geo.country @@ -1319,6 +1181,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/overview: parameters: - *a3 @@ -1329,64 +1193,12 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" responses: "200": description: Successful response @@ -1411,6 +1223,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/timeseries: parameters: - *a3 @@ -1421,76 +1235,13 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" responses: "200": description: Successful response @@ -1522,6 +1273,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/breakdowns/{dimension}: parameters: - *a3 @@ -1533,64 +1286,12 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: metrics - in: query - style: form - explode: false - schema: - type: array - items: - type: string - enum: - - views - - sessions - - visitors - - bounces - - bounceRate - - avgDurationMs - - viewsPerSession - - events - description: Comma-separated metrics to include. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/MetricsQueryParam" - name: limit in: query schema: @@ -1631,68 +1332,37 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/cross-breakdowns: parameters: - *a3 get: operationId: getAnalyticsCrossBreakdown summary: Get analytics cross breakdown - description: Returns a two-dimensional analytics breakdown. + description: Returns a two-dimensional analytics breakdown. Supports page, + referrer, UTM, client, and geo dimensions. Session and event dimensions + are not supported. tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" - name: primary in: query schema: type: string maxLength: 120 - description: Primary dimension. + description: Primary dimension (e.g. client.browser, geo.country, page.path). - name: secondary in: query schema: type: string maxLength: 120 - description: Secondary dimension. + description: Secondary dimension (must differ from primary). - name: metric in: query schema: @@ -1732,6 +1402,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/compare: parameters: - *a3 @@ -1742,46 +1414,11 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" - name: compare in: query schema: @@ -1825,6 +1462,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/explore: parameters: - *a3 @@ -1897,6 +1536,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/retention/cohorts: parameters: - *a3 @@ -1907,58 +1548,12 @@ paths: tags: - Analytics parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" responses: "200": description: Successful response @@ -1993,6 +1588,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/event-types: parameters: - *a3 @@ -2003,39 +1600,10 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" - name: limit in: query schema: @@ -2077,10 +1645,12 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/event-types/{eventName}: parameters: - *a3 - - $ref: "#/components/parameters/eventName" + - $ref: "#/components/parameters/EventNamePathParam" get: operationId: getEventType summary: Get event type @@ -2088,51 +1658,11 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" responses: "200": description: Successful response @@ -2180,6 +1710,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events: parameters: - *a3 @@ -2190,60 +1722,13 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" - name: sort in: query schema: @@ -2292,6 +1777,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/summary: parameters: - *a3 @@ -2302,46 +1789,11 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" responses: "200": description: Successful response @@ -2370,6 +1822,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/timeseries: parameters: - *a3 @@ -2380,58 +1834,12 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" - name: eventName in: query schema: @@ -2469,6 +1877,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/search: parameters: - *a3 @@ -2527,10 +1937,12 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/{eventId}: parameters: - *a3 - - $ref: "#/components/parameters/eventId" + - $ref: "#/components/parameters/EventIdPathParam" get: operationId: getEvent summary: Get event @@ -2558,6 +1970,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/event-fields/values: parameters: - *a3 @@ -2568,39 +1982,10 @@ paths: tags: - Events parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" - name: eventName in: query schema: @@ -2613,6 +1998,18 @@ paths: type: string maxLength: 240 description: Field path. + - name: fieldValueType + in: query + schema: + type: string + enum: + - string + - number + - boolean + - "null" + - object + - array + description: Expected value type for the field. - name: search in: query schema: @@ -2651,6 +2048,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/visitors: parameters: - *a3 @@ -2661,60 +2060,13 @@ paths: tags: - Visitors parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" - name: sort in: query schema: @@ -2765,11 +2117,13 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/visitors/{visitorId}: parameters: - *a3 - &a8 - $ref: "#/components/parameters/visitorId" + $ref: "#/components/parameters/VisitorIdPathParam" get: operationId: getVisitor summary: Get visitor @@ -2787,74 +2141,35 @@ paths: default: summary: getVisitor value: - data: *a7 - meta: - requestId: req_abc123 - generatedAt: 2026-06-26T12:00:00Z - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/sites/{siteId}/visitors/{visitorId}/sessions: - parameters: - - *a3 - - *a8 - get: - operationId: listVisitorSessions - summary: List visitor sessions - description: Lists sessions for a visitor. - tags: - - Visitors - parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + data: *a7 + meta: + requestId: req_abc123 + generatedAt: 2026-06-26T12:00:00Z + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read + /api/v1/sites/{siteId}/visitors/{visitorId}/sessions: + parameters: + - *a3 + - *a8 + get: + operationId: listVisitorSessions + summary: List visitor sessions + description: Lists sessions for a visitor. + tags: + - Visitors + parameters: + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" responses: "200": description: Successful response @@ -2892,6 +2207,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/visitors/{visitorId}/events: parameters: - *a3 @@ -2903,53 +2220,12 @@ paths: tags: - Visitors parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" responses: "200": description: Successful response @@ -2978,6 +2254,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/sessions: parameters: - *a3 @@ -2988,60 +2266,13 @@ paths: tags: - Sessions parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" - name: sort in: query schema: @@ -3082,11 +2313,13 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/sessions/{sessionId}: parameters: - *a3 - &a10 - $ref: "#/components/parameters/sessionId" + $ref: "#/components/parameters/SessionIdPathParam" get: operationId: getSession summary: Get session @@ -3114,6 +2347,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/sessions/{sessionId}/events: parameters: - *a3 @@ -3125,53 +2360,12 @@ paths: tags: - Sessions parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - description: Maximum number of results. - - name: cursor - in: query - schema: - type: string - maxLength: 512 - description: Opaque pagination cursor from the previous response. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/CursorQueryParam" responses: "200": description: Successful response @@ -3200,6 +2394,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/funnels: parameters: - *a3 @@ -3210,39 +2406,10 @@ paths: tags: - Funnels parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" responses: "200": description: Successful response @@ -3283,6 +2450,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read post: operationId: createFunnel summary: Create funnel @@ -3332,9 +2501,15 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write /api/v1/sites/{siteId}/funnels/analysis: parameters: - *a3 + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" post: operationId: analyzeFunnel summary: Analyze funnel @@ -3351,14 +2526,7 @@ paths: default: summary: Analyze an ad-hoc funnel value: - timeRange: *a1 steps: *a11 - filters: - - field: geo.country - op: in - value: - - US - - CA responses: "200": description: Successful response @@ -3370,7 +2538,7 @@ paths: default: summary: analyzeFunnel value: - data: &a13 + data: &a14 steps: - index: 0 label: Pricing @@ -3409,10 +2577,13 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/funnels/{funnelId}: parameters: - *a3 - - $ref: "#/components/parameters/funnelId" + - &a13 + $ref: "#/components/parameters/FunnelIdPathParam" get: operationId: getFunnel summary: Get funnel @@ -3440,6 +2611,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read patch: operationId: updateFunnel summary: Update funnel @@ -3481,6 +2654,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write delete: operationId: deleteFunnel summary: Delete funnel @@ -3496,10 +2671,12 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write /api/v1/sites/{siteId}/funnels/{funnelId}/analysis: parameters: - *a3 - - $ref: "#/components/parameters/funnelId" + - *a13 get: operationId: getFunnelAnalysis summary: Get funnel analysis @@ -3507,39 +2684,10 @@ paths: tags: - Funnels parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" responses: "200": description: Successful response @@ -3553,7 +2701,7 @@ paths: value: data: funnel: *a12 - analysis: *a13 + analysis: *a14 meta: requestId: req_abc123 generatedAt: 2026-06-26T12:00:00Z @@ -3566,6 +2714,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/performance/summary: parameters: - *a3 @@ -3576,46 +2726,11 @@ paths: tags: - Performance parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" responses: "200": description: Successful response @@ -3644,6 +2759,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/performance/timeseries: parameters: - *a3 @@ -3654,58 +2771,12 @@ paths: tags: - Performance parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: interval - in: query - schema: - type: string - enum: - - minute - - hour - - day - - week - - month - default: day - description: Time bucket granularity. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/IntervalQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" responses: "200": description: Successful response @@ -3738,6 +2809,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/performance/breakdowns/{dimension}: parameters: - *a3 @@ -3749,46 +2822,11 @@ paths: tags: - Performance parameters: - - name: from - in: query - schema: - type: string - format: date-time - description: - Inclusive ISO 8601 start time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: to - in: query - schema: - type: string - format: date-time - description: - Exclusive ISO 8601 end time. If from, to, and preset are omitted, - analytics endpoints default to the last 7 days ending at request - time. - - name: preset - in: query - schema: - $ref: "#/components/schemas/Preset" - description: - Named time range preset. Mutually exclusive with from and to. If - from, to, and preset are omitted, analytics endpoints default to the - last 7 days ending at request time. - - name: timeZone - in: query - schema: - type: string - maxLength: 80 - default: UTC - description: IANA time zone used to resolve presets. Defaults to UTC. - - name: filter - in: query - style: deepObject - explode: true - schema: - $ref: "#/components/schemas/FilterObject" - description: Simple equality filters as filter[field]=value. + - $ref: "#/components/parameters/FromQueryParam" + - $ref: "#/components/parameters/ToQueryParam" + - $ref: "#/components/parameters/PresetQueryParam" + - $ref: "#/components/parameters/TimeZoneQueryParam" + - $ref: "#/components/parameters/FilterQueryParam" - name: metric in: query schema: @@ -3827,6 +2865,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/active-visitors: parameters: - *a3 @@ -3858,6 +2898,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/events: parameters: - *a3 @@ -3899,6 +2941,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/sessions: parameters: - *a3 @@ -3940,6 +2984,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/snapshot: parameters: - *a3 @@ -3975,6 +3021,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/batch: post: operationId: batch @@ -4045,6 +3093,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: + - analytics:read components: schemas: Meta: @@ -4263,10 +3313,11 @@ components: ComplexFilter: type: object description: - "Advanced filter rule for explore and search endpoints. Operators: - eq equals; neq does not equal; in is one of; notIn is not one of; - contains includes substring; startsWith/endsWith match string edges; - gt/gte/lt/lte compare ordered values; exists/notExists ignore value." + Advanced filter rule for explore and search endpoints. For eq, neq, + contains, startsWith, and endsWith, use a scalar value. For in and + notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO + 8601 date-time string depending on the field. For exists and notExists, + value may be omitted. required: - field - op @@ -4294,44 +3345,83 @@ components: - lte - exists - notExists - value: {} + value: + oneOf: + - type: string + - type: number + - type: boolean + - type: array + items: + oneOf: + - type: string + - type: number + - type: boolean MetricDefinition: type: object description: Metric available for analytics queries. required: + - id - key - label - type - description properties: + id: + type: string key: type: string label: type: string + description: + type: string + unit: + type: string + enum: + - count + - ratio + - milliseconds type: type: string enum: - integer + - number - rate - - duration_ms - description: + aggregation: type: string + enum: + - sum + - average + - ratio + - derived + filterable: + type: boolean + sortable: + type: boolean DimensionDefinition: type: object description: Dimension available for analytics breakdowns and filters. required: + - id - key - label - type properties: + id: + type: string key: type: string label: type: string - type: - type: string description: type: string + type: + type: string + filterable: + type: boolean + groupable: + type: boolean + sortable: + type: boolean SiteAccess: type: object description: Sites this token may access. @@ -4349,7 +3439,7 @@ components: restricted means only listed siteIds. siteIds: type: array - items: &a14 + items: &a15 type: string format: uuid Token: @@ -4363,7 +3453,7 @@ components: - scopes - siteAccess properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 @@ -4374,7 +3464,7 @@ components: - expired - revoked description: active can be used; expired passed expiresAt; revoked was disabled. - createdAt: &a15 + createdAt: &a16 type: string format: date-time expiresAt: @@ -4393,7 +3483,7 @@ components: - id - name properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 @@ -4435,7 +3525,7 @@ components: scope: type: string maxLength: 80 - siteId: *a14 + siteId: *a15 TokenCheckResponse: description: Response envelope. allOf: @@ -4453,7 +3543,7 @@ components: properties: scope: type: string - siteId: *a14 + siteId: *a15 allowed: type: boolean reason: @@ -4580,11 +3670,11 @@ components: Team: type: object properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 - createdAt: *a15 + createdAt: *a16 links: $ref: "#/components/schemas/LinkMap" TeamResponse: @@ -4608,15 +3698,15 @@ components: - sharing - links properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 domain: type: string maxLength: 255 - createdAt: *a15 - updatedAt: *a15 + createdAt: *a16 + updatedAt: *a16 sharing: $ref: "#/components/schemas/SharingSettings" links: @@ -4636,11 +3726,18 @@ components: type: string minLength: 1 maxLength: 255 - sharing: - $ref: "#/components/schemas/SharingSettings" + publicEnabled: + type: boolean + default: false + description: Whether the public sharing link is enabled. + publicSlug: + type: string + maxLength: 120 + description: Optional public sharing slug when publicEnabled is true. + additionalProperties: false SiteUpdateInput: type: object - description: Partial update for site metadata and sharing settings. + description: Partial update for site metadata and public sharing input fields. properties: name: type: string @@ -4650,8 +3747,14 @@ components: type: string minLength: 1 maxLength: 255 - sharing: - $ref: "#/components/schemas/SharingSettings" + publicEnabled: + type: boolean + description: Whether the public sharing link is enabled. + publicSlug: + type: string + maxLength: 120 + description: Optional public sharing slug when publicEnabled is true. + additionalProperties: false SiteResponse: description: Response envelope. allOf: @@ -4729,7 +3832,7 @@ components: data: type: object properties: - siteId: *a14 + siteId: *a15 src: type: string format: uri @@ -4749,6 +3852,14 @@ components: type: string enum: - daily + - weekly + - monthly + - session + - none + description: + Visitor token rotation mode. The current runtime behavior uses + daily tokens; additional values are reserved for compatible future + configuration. dataRetentionDays: type: integer minimum: 1 @@ -4826,7 +3937,7 @@ components: - string - "null" format: date-time - latestAvailableAt: *a15 + latestAvailableAt: *a16 links: $ref: "#/components/schemas/LinkMap" OverviewMetrics: @@ -4865,8 +3976,8 @@ components: type: object description: One time bucket of analytics metrics. properties: - start: *a15 - end: *a15 + start: *a16 + end: *a16 views: type: integer sessions: @@ -5085,7 +4196,7 @@ components: items: type: object properties: - start: *a15 + start: *a16 size: type: integer minimum: 0 @@ -5144,11 +4255,11 @@ components: type: object additionalProperties: true properties: - id: *a14 + id: *a15 eventName: type: string maxLength: 120 - occurredAt: *a15 + occurredAt: *a16 EventListResponse: description: Response envelope for paginated list results. allOf: @@ -5290,6 +4401,16 @@ components: - notExists value: description: Comparison value. Required unless op is exists or notExists. + oneOf: + - type: string + - type: number + - type: boolean + - type: array + items: + oneOf: + - type: string + - type: number + - type: boolean additionalProperties: false EventSearchRequest: type: object @@ -5326,8 +4447,8 @@ components: visitorId: type: string maxLength: 160 - firstSeenAt: *a15 - lastSeenAt: *a15 + firstSeenAt: *a16 + lastSeenAt: *a16 views: type: integer minimum: 0 @@ -5370,7 +4491,7 @@ components: visitorId: type: string maxLength: 160 - startedAt: *a15 + startedAt: *a16 endedAt: type: - string @@ -5438,8 +4559,8 @@ components: description: Performance metric point. additionalProperties: true properties: - start: *a15 - end: *a15 + start: *a16 + end: *a16 ttfb: type: number description: Time to first byte in milliseconds. @@ -5568,22 +4689,18 @@ components: additionalProperties: false FunnelAnalysisRequest: type: object - description: Request for ad-hoc funnel analysis. + description: + Request for ad-hoc funnel analysis. Use query parameters (from, to, + preset, timeZone) for time range. required: - steps properties: - timeRange: - $ref: "#/components/schemas/TimeRangeInput" steps: type: array minItems: 2 maxItems: 10 items: $ref: "#/components/schemas/FunnelStepInput" - filters: - type: array - items: - $ref: "#/components/schemas/ComplexFilter" additionalProperties: false FunnelStep: type: object @@ -5614,8 +4731,8 @@ components: - createdAt - updatedAt properties: - id: *a14 - siteId: *a14 + id: *a15 + siteId: *a15 name: type: string maxLength: 200 @@ -5628,8 +4745,8 @@ components: type: array items: $ref: "#/components/schemas/FunnelStep" - createdAt: *a15 - updatedAt: *a15 + createdAt: *a16 + updatedAt: *a16 links: $ref: "#/components/schemas/LinkMap" FunnelResponse: @@ -5852,7 +4969,7 @@ components: type: string enum: - healthy - timestamp: *a15 + timestamp: *a16 CollectPage: type: object description: Page context for a collect payload. @@ -6023,7 +5140,7 @@ components: scheme: bearer description: API key passed as a Bearer token in the Authorization header. parameters: - siteId: + SiteIdPathParam: name: siteId in: path required: true @@ -6031,7 +5148,7 @@ components: type: string format: uuid description: Site UUID. - dimension: + DimensionPathParam: name: dimension in: path required: true @@ -6039,7 +5156,7 @@ components: type: string maxLength: 120 description: Stable analytics dimension key. - eventName: + EventNamePathParam: name: eventName in: path required: true @@ -6047,7 +5164,7 @@ components: type: string maxLength: 120 description: Event name. - eventId: + EventIdPathParam: name: eventId in: path required: true @@ -6055,23 +5172,23 @@ components: type: string format: uuid description: Event UUID. - visitorId: + VisitorIdPathParam: name: visitorId in: path required: true schema: type: string - format: uuid - description: Visitor UUID. - sessionId: + maxLength: 160 + description: Opaque visitor identifier. + SessionIdPathParam: name: sessionId in: path required: true schema: type: string - format: uuid - description: Session UUID. - funnelId: + maxLength: 160 + description: Opaque session identifier. + FunnelIdPathParam: name: funnelId in: path required: true @@ -6079,28 +5196,119 @@ components: type: string format: uuid description: Funnel UUID. + FromQueryParam: + name: from + in: query + schema: + type: string + format: date-time + description: + Inclusive ISO 8601 start time. If from, to, and preset are omitted, + analytics endpoints default to the last 7 days ending at request time. + ToQueryParam: + name: to + in: query + schema: + type: string + format: date-time + description: + Exclusive ISO 8601 end time. If from, to, and preset are omitted, + analytics endpoints default to the last 7 days ending at request time. + PresetQueryParam: + name: preset + in: query + schema: + $ref: "#/components/schemas/Preset" + description: + Named time range preset. Mutually exclusive with from and to. If + from, to, and preset are omitted, analytics endpoints default to the + last 7 days ending at request time. + TimeZoneQueryParam: + name: timeZone + in: query + schema: + type: string + maxLength: 80 + default: UTC + description: IANA time zone used to resolve presets. Defaults to UTC. + IntervalQueryParam: + name: interval + in: query + schema: + type: string + enum: + - minute + - hour + - day + - week + - month + default: day + description: Time bucket granularity. + MetricsQueryParam: + name: metrics + in: query + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - views + - sessions + - visitors + - bounces + - bounceRate + - avgDurationMs + - viewsPerSession + - events + description: Comma-separated metrics to include. + FilterQueryParam: + name: filter + in: query + style: deepObject + explode: true + schema: + $ref: "#/components/schemas/FilterObject" + description: Simple equality filters as filter[field]=value. + LimitQueryParam: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + description: Maximum number of results. + CursorQueryParam: + name: cursor + in: query + schema: + type: string + maxLength: 512 + description: Opaque pagination cursor from the previous response. responses: BadRequest: description: Bad request - content: &a16 + content: &a17 application/json: schema: $ref: "#/components/schemas/ErrorResponse" Unauthorized: description: Authentication failed - content: *a16 + content: *a17 Forbidden: description: Insufficient permissions - content: *a16 + content: *a17 NotFound: description: Resource not found - content: *a16 + content: *a17 Conflict: description: Conflict - content: *a16 + content: *a17 PayloadTooLarge: description: Payload too large - content: *a16 + content: *a17 InternalError: description: Internal error - content: *a16 + content: *a17 diff --git a/docs/skills.json b/docs/skills.json index b6df01ab..c5d8e408 100644 --- a/docs/skills.json +++ b/docs/skills.json @@ -1,8 +1,8 @@ { "api": "InsightFlare Analytics API", - "version": "1.0.0", + "version": "0.2.0", "description": "Privacy-focused web analytics platform.", - "baseUrl": "https://insight.ravelloh.com", + "baseUrl": "${baseUrl}", "openapiUrl": "/.well-known/openapi.json", "discovery": { "root": "/api/v1", diff --git a/eslint.config.js b/eslint.config.js index 327dd457..bcebf5b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,7 +110,12 @@ export default [ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["*.ts", "src/app/.well-known/*/route.ts"], + allowDefaultProject: [ + "*.ts", + "src/app/.well-known/change-password/route.ts", + "src/app/.well-known/health/route.ts", + "src/app/.well-known/security.txt/route.ts", + ], }, tsconfigRootDir: import.meta.dirname, }, diff --git a/next.config.ts b/next.config.ts index 89737466..aac68f99 100644 --- a/next.config.ts +++ b/next.config.ts @@ -57,7 +57,7 @@ const nextConfig: NextConfig = { "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data:", - "connect-src 'self' https://cdn.jsdelivr.net https://*.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com https://insight.ravelloh.com wss://*.insight.ravelloh.com", + "connect-src 'self' https: wss:", "frame-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", diff --git a/package-lock.json b/package-lock.json index 7fe8929d..dc3ff566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,31 @@ { "name": "insightflare", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "insightflare", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@deck.gl/core": "^9.3.2", - "@deck.gl/geo-layers": "^9.3.2", "@deck.gl/layers": "^9.3.2", "@deck.gl/mapbox": "^9.3.2", - "@deck.gl/react": "^9.3.2", "@iconify-json/flagpack": "^1.2.7", "@iconify/react": "^6.0.2", "@noble/hashes": "^2.2.0", "@number-flow/react": "^0.6.0", "@remixicon/react": "^4.9.0", - "apache-arrow": "21.1.0", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "hono": "^4.12.27", "i18n-iso-countries": "^7.14.0", "maplibre-gl": "^5.24.0", "motion": "^12.40.0", "next": "^16.2.6", "next-themes": "^0.4.6", "overlayscrollbars": "^2.16.0", - "parquet-wasm": "^0.7.1", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-day-picker": "^9.14.0", @@ -53,13 +50,11 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.7", - "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "~9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.5.0", @@ -76,8 +71,7 @@ "vitest": "^4.1.7", "wrangler": "^4.94.0", "yaml": "^2.9.0", - "yaml-loader": "^0.9.0", - "zod-openapi": "^5.4.6" + "yaml-loader": "^0.9.0" }, "optionalDependencies": { "@ast-grep/napi-linux-x64-gnu": "0.42.3" @@ -1609,24 +1603,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-proposal-private-methods": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", - "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-jsx": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", @@ -1947,57 +1923,6 @@ "mjolnir.js": "^3.0.0" } }, - "node_modules/@deck.gl/extensions": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.3.4.tgz", - "integrity": "sha512-pzqPJMnzCLJpPWTS9QE3g4DX5usXwJMpYXvbqh9/DYQSCYw+gN+VpbVbvfGNLzUtXFhnp1RbRS+7Y0KDFEy8Dw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@luma.gl/shadertools": "^9.3.3", - "@luma.gl/webgl": "^9.3.3", - "@math.gl/core": "^4.1.0" - }, - "peerDependencies": { - "@deck.gl/core": "~9.3.0", - "@luma.gl/core": "~9.3.3", - "@luma.gl/engine": "~9.3.3" - } - }, - "node_modules/@deck.gl/geo-layers": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.3.4.tgz", - "integrity": "sha512-NEfBqU/5fk5A+ZhTmiTvb5lPeRka6l/1bPSCyk0CSeyP2M7y6gJtT+EmtQAduIFl5YMp2sO+5tIu6NbnfxQbrg==", - "license": "MIT", - "dependencies": { - "@loaders.gl/3d-tiles": "^4.4.1", - "@loaders.gl/gis": "^4.4.1", - "@loaders.gl/loader-utils": "^4.4.1", - "@loaders.gl/mvt": "^4.4.1", - "@loaders.gl/schema": "^4.4.1", - "@loaders.gl/terrain": "^4.4.1", - "@loaders.gl/tiles": "^4.4.1", - "@loaders.gl/wms": "^4.4.1", - "@luma.gl/gltf": "^9.3.3", - "@luma.gl/shadertools": "^9.3.3", - "@math.gl/core": "^4.1.0", - "@math.gl/culling": "^4.1.0", - "@math.gl/web-mercator": "^4.1.0", - "@types/geojson": "^7946.0.8", - "a5-js": "^0.7.2", - "h3-js": "^4.4.0", - "long": "^3.2.0" - }, - "peerDependencies": { - "@deck.gl/core": "~9.3.0", - "@deck.gl/extensions": "~9.3.0", - "@deck.gl/layers": "~9.3.0", - "@deck.gl/mesh-layers": "~9.3.0", - "@loaders.gl/core": "^4.4.1", - "@luma.gl/core": "~9.3.3", - "@luma.gl/engine": "~9.3.3" - } - }, "node_modules/@deck.gl/layers": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.3.4.tgz", @@ -2034,53 +1959,6 @@ "@math.gl/web-mercator": "^4.1.0" } }, - "node_modules/@deck.gl/mesh-layers": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.3.4.tgz", - "integrity": "sha512-07nBD9hLphVjZ4bHN9FwyUOdR1+Zvlcu0Wxd5dnxQIer2CySSaenPJgXXUg629tKUxvmcLAgs4V2klef+Z8YFg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@loaders.gl/gltf": "^4.4.1", - "@loaders.gl/schema": "^4.4.1", - "@luma.gl/gltf": "^9.3.3", - "@luma.gl/shadertools": "^9.3.3" - }, - "peerDependencies": { - "@deck.gl/core": "~9.3.0", - "@luma.gl/core": "~9.3.3", - "@luma.gl/engine": "~9.3.3", - "@luma.gl/gltf": "~9.3.3", - "@luma.gl/shadertools": "~9.3.3" - } - }, - "node_modules/@deck.gl/react": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/react/-/react-9.3.4.tgz", - "integrity": "sha512-ZdjA/NSNN0vui1XA2l7vdZIBfr50cFxGzIpbxpBxppbvBio/kesRvQGXTY1bCfvJ32+cDESALZzdMZNlWMf64Q==", - "license": "MIT", - "peerDependencies": { - "@deck.gl/core": "~9.3.0", - "@deck.gl/widgets": "~9.3.0", - "react": ">=16.3.0", - "react-dom": ">=16.3.0" - } - }, - "node_modules/@deck.gl/widgets": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@deck.gl/widgets/-/widgets-9.3.4.tgz", - "integrity": "sha512-IgAUuZWhSq7nre5FVg5XtLqBfN+pNhrzLlRCM0hnFi2/YuHJOv7n2nKQzmtE5UXCEEvZbx62Fe8jtelexdYqkg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@floating-ui/dom": "^1.7.5", - "preact": "^10.17.0" - }, - "peerDependencies": { - "@deck.gl/core": "~9.3.0", - "@luma.gl/core": "~9.3.3" - } - }, "node_modules/@dotenvx/dotenvx": { "version": "1.31.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.31.0.tgz", @@ -3563,60 +3441,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@loaders.gl/3d-tiles": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.4.3.tgz", - "integrity": "sha512-trKDXRYE7xIyH0g2tvDG0SHo/J5sCUhOec70Ne1a5t64/yY+yifQ4B67n4AnSOnZ+l4sxjUypjxhHUqoAeWieQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/compression": "4.4.3", - "@loaders.gl/crypto": "4.4.3", - "@loaders.gl/draco": "4.4.3", - "@loaders.gl/gltf": "4.4.3", - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/math": "4.4.3", - "@loaders.gl/tiles": "4.4.3", - "@loaders.gl/zip": "4.4.3", - "@math.gl/core": "^4.1.0", - "@math.gl/culling": "^4.1.0", - "@math.gl/geospatial": "^4.1.0", - "@probe.gl/log": "^4.1.1", - "long": "^5.2.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/3d-tiles/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/@loaders.gl/compression": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.4.3.tgz", - "integrity": "sha512-v3feEE48FblxBPaILwejV48plcLmjvOh2yBQrgvKvLCaQdQ9bVz7PzhrUdLW0VDVlGnoR1NUJtI833627U8Heg==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/worker-utils": "4.4.3", - "@types/pako": "^1.0.1", - "fflate": "0.7.4", - "pako": "1.0.11", - "snappyjs": "^0.6.1" - }, - "optionalDependencies": { - "@types/brotli": "^1.3.0", - "brotli": "^1.3.2", - "lz4js": "^0.2.0", - "zstd-codec": "^0.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, "node_modules/@loaders.gl/core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.4.3.tgz", @@ -3630,84 +3454,6 @@ "@probe.gl/log": "^4.1.1" } }, - "node_modules/@loaders.gl/crypto": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.4.3.tgz", - "integrity": "sha512-zratEvtj/Mdbu0NwwwzdbP1oyY4FNxLcOY2JRcYqe3wzw+0kyeK7THdaVPcduZAnQ06HtpwHls61ONZunjMtXA==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/worker-utils": "4.4.3", - "@types/crypto-js": "^4.0.2" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/draco": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.4.3.tgz", - "integrity": "sha512-YNI1MUDDIbrJBamgU1emLBC2kQUbESNIOZv9bcS8xwxr9SNMfnAMZWgdUJkYcC5xzV2q6ty2I/b2eGhXQkd9EQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@loaders.gl/schema-utils": "4.4.3", - "@loaders.gl/worker-utils": "4.4.3", - "draco3d": "1.5.7" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/geoarrow": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/geoarrow/-/geoarrow-4.4.3.tgz", - "integrity": "sha512-P0TSbtsw1UI1rZnp1tGHuqPVHcH7Yc14q9jZLIcrjhQOzol55YDI0/hYSXpA73794nR6o8ALxNwiy7/4HlBlcA==", - "license": "MIT", - "dependencies": { - "@math.gl/polygon": "^4.1.0", - "apache-arrow": ">= 17.0.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/gis": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.4.3.tgz", - "integrity": "sha512-2dhhzfCT1cXQQLZ1lMtb2Y+pX414qaeLL8Wpx7FJu/ar1HJfKL6Fb3juTR+2WzMybkAOX5zLxYh6+y7LqiPjgA==", - "license": "MIT", - "dependencies": { - "@loaders.gl/geoarrow": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@loaders.gl/schema-utils": "4.4.3", - "@mapbox/vector-tile": "^1.3.1", - "@math.gl/polygon": "^4.1.0", - "pbf": "^3.2.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/gltf": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.4.3.tgz", - "integrity": "sha512-OYE/0nGLYm3weiCb78+ROwdNVyuLS8IZwN8NvGqctqt0HS4ZD40biu7b9L8xkFbVTZQkQZXDpyZ61n5PSQeU1A==", - "license": "MIT", - "dependencies": { - "@loaders.gl/draco": "4.4.3", - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@loaders.gl/textures": "4.4.3", - "@math.gl/core": "^4.1.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, "node_modules/@loaders.gl/images": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.4.3.tgz", @@ -3732,36 +3478,6 @@ "@probe.gl/stats": "^4.1.1" } }, - "node_modules/@loaders.gl/math": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.4.3.tgz", - "integrity": "sha512-EdEsZGqo1AX3sqgXt8bSBmdo+8ncURXjWucyQ8eeIJ2h0VIqM3bLkOGKk/D1ngeqK4hJXcjiturYHBW1B286lw==", - "license": "MIT", - "dependencies": { - "@math.gl/core": "^4.1.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/mvt": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.4.3.tgz", - "integrity": "sha512-s2R8GRlaWDfZ7oKDFiY0c5EJ8elkf2VbPvdC0eoh69DN71y+AKngDSB+7h4veH/oueLEjmV7tNlF0+Snv58CPw==", - "license": "MIT", - "dependencies": { - "@loaders.gl/gis": "4.4.3", - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@math.gl/polygon": "^4.1.0", - "@probe.gl/stats": "^4.1.1", - "pbf": "^3.2.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, "node_modules/@loaders.gl/schema": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.4.3.tgz", @@ -3786,74 +3502,6 @@ "@loaders.gl/core": "~4.4.0" } }, - "node_modules/@loaders.gl/terrain": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.4.3.tgz", - "integrity": "sha512-wu7O3FVXE6D1kSm5inRu8M8V1Vv5AYlnlNB0EJIOSSXBLhfNbPL5oHjlD2wIlag0gSxOVlhWjCUcWS3/Nb0P8Q==", - "license": "MIT", - "dependencies": { - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@mapbox/martini": "^0.2.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/textures": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.4.3.tgz", - "integrity": "sha512-QNC+anwJJcHsLsaAzXozJ+IUNxBnqWsZLUMkUZIorVey+XQcp2hcYng5iOmUQnCZdIR9gYe53VJa0j1C0JXRSg==", - "license": "MIT", - "dependencies": { - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@loaders.gl/worker-utils": "4.4.3", - "@math.gl/types": "^4.1.0", - "ktx-parse": "^0.7.0", - "texture-compressor": "^1.0.2" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/tiles": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.4.3.tgz", - "integrity": "sha512-8ZkPMe+daMV5mHKTEU591mJSp6pswdWliI+PcNhSOkpeuvyjecaYKSzuzMFMmaywfgOLpYl+lpfzKUEmCcE5vQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/math": "4.4.3", - "@math.gl/core": "^4.1.0", - "@math.gl/culling": "^4.1.0", - "@math.gl/geospatial": "^4.1.0", - "@math.gl/web-mercator": "^4.1.0", - "@probe.gl/stats": "^4.1.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/wms": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.4.3.tgz", - "integrity": "sha512-fKYCdIp6xQcucbm42bztprlfMJ5SZKx5f2ZwWLcj+kwv53U7hjcbIl04uMAk/i/7Xw/I4QEyA/XnPnxquuIFHQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/images": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "@loaders.gl/xml": "4.4.3", - "@turf/rewind": "^5.1.5", - "deep-strict-equal": "^0.2.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, "node_modules/@loaders.gl/worker-utils": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.4.3.tgz", @@ -3863,36 +3511,6 @@ "@loaders.gl/core": "~4.4.0" } }, - "node_modules/@loaders.gl/xml": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.4.3.tgz", - "integrity": "sha512-/CtpIWaPKBs/AaX7MqUGSSE1OAhq78nDrJ5xPExYdIQAw5gl5OSmLZobUSSxV5FGaqkJRVbtC/sj5sUpci7doA==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.3", - "@loaders.gl/schema": "4.4.3", - "fast-xml-parser": "^5.3.6" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/zip": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.4.3.tgz", - "integrity": "sha512-HH/JLulJJVyqmbQYp4e/9MPjaTnUFnWfhgza8sMGIBYZ/YuZXOc6Ad2vvmyFQHvNbVr+NLrRZODASEjNZQ5rfQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/compression": "4.4.3", - "@loaders.gl/crypto": "4.4.3", - "@loaders.gl/loader-utils": "4.4.3", - "jszip": "^3.1.5", - "md5": "^2.3.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, "node_modules/@luma.gl/core": { "version": "9.3.5", "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.3.5.tgz", @@ -3922,23 +3540,6 @@ "@luma.gl/shadertools": "~9.3.0" } }, - "node_modules/@luma.gl/gltf": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.3.5.tgz", - "integrity": "sha512-uLzXk2hDyzocME3SJquDHDZDm5HALRV2C+TnEXmKnp1Y6XNNR5aQF7n5DbvdCMQppgtVrMwRaMAvSgNs83nEYQ==", - "license": "MIT", - "dependencies": { - "@loaders.gl/core": "~4.4.0", - "@loaders.gl/gltf": "~4.4.0", - "@loaders.gl/textures": "~4.4.0", - "@math.gl/core": "^4.1.0" - }, - "peerDependencies": { - "@luma.gl/core": "~9.3.0", - "@luma.gl/engine": "~9.3.0", - "@luma.gl/shadertools": "~9.3.0" - } - }, "node_modules/@luma.gl/shadertools": { "version": "9.3.5", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.3.5.tgz", @@ -3973,18 +3574,6 @@ "node": ">= 0.6" } }, - "node_modules/@mapbox/martini": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", - "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", - "license": "ISC" - }, - "node_modules/@mapbox/point-geometry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", - "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", - "license": "ISC" - }, "node_modules/@mapbox/tiny-sdf": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", @@ -3997,15 +3586,6 @@ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "license": "BSD-2-Clause" }, - "node_modules/@mapbox/vector-tile": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", - "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", - "license": "BSD-3-Clause", - "dependencies": { - "@mapbox/point-geometry": "~0.1.0" - } - }, "node_modules/@mapbox/whoots-js": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", @@ -4102,36 +3682,16 @@ "@math.gl/types": "4.1.0" } }, - "node_modules/@math.gl/culling": { + "node_modules/@math.gl/polygon": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", - "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", "license": "MIT", "dependencies": { - "@math.gl/core": "4.1.0", - "@math.gl/types": "4.1.0" + "@math.gl/core": "4.1.0" } }, - "node_modules/@math.gl/geospatial": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", - "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", - "license": "MIT", - "dependencies": { - "@math.gl/core": "4.1.0", - "@math.gl/types": "4.1.0" - } - }, - "node_modules/@math.gl/polygon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", - "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", - "license": "MIT", - "dependencies": { - "@math.gl/core": "4.1.0" - } - }, - "node_modules/@math.gl/sun": { + "node_modules/@math.gl/sun": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", @@ -4454,18 +4014,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@node-minify/core": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@node-minify/core/-/core-8.0.6.tgz", @@ -7510,62 +7058,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@turf/boolean-clockwise": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", - "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^5.1.5", - "@turf/invariant": "^5.1.5" - } - }, - "node_modules/@turf/clone": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", - "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^5.1.5" - } - }, - "node_modules/@turf/helpers": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", - "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", - "license": "MIT" - }, - "node_modules/@turf/invariant": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", - "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^5.1.5" - } - }, - "node_modules/@turf/meta": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", - "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^5.1.5" - } - }, - "node_modules/@turf/rewind": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", - "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", - "license": "MIT", - "dependencies": { - "@turf/boolean-clockwise": "^5.1.5", - "@turf/clone": "^5.1.5", - "@turf/helpers": "^5.1.5", - "@turf/invariant": "^5.1.5", - "@turf/meta": "^5.1.5" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -7577,16 +7069,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/brotli": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.5.tgz", - "integrity": "sha512-9xoNr+bcxT236/7ZgcWw/6Pb2RRetE13p4bFy1xYSckKwyOiRfmInay8baUWZgH7/284Wl6IPe7+nOI9+OQg/A==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -7610,12 +7092,6 @@ "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", "license": "MIT" }, - "node_modules/@types/crypto-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", - "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", - "license": "MIT" - }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -7732,12 +7208,6 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, - "node_modules/@types/pako": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", - "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -8268,15 +7738,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/a5-js": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.7.3.tgz", - "integrity": "sha512-3aoMwHmNkyuMDHS4q6GRRInpOawamen2pokIbc0MQmR9cqG0Y9+B0bZpzswwetjrSG2ckbYtShH+nKru6+3O5Q==", - "license": "Apache-2.0", - "dependencies": { - "gl-matrix": "^3.4.3" - } - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -8460,18 +7921,6 @@ "node": ">=14" } }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/apache-arrow": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", @@ -8787,27 +8236,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -8904,16 +8332,6 @@ "node": ">=8" } }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -8947,15 +8365,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buf-compare": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", - "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9176,15 +8585,6 @@ "node": ">=8" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -9489,171 +8889,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concurrently": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", - "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.4", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/concurrently/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/concurrently/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/conf": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", @@ -9785,25 +9020,6 @@ "node": ">=6.6.0" } }, - "node_modules/core-assert": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", - "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", - "license": "MIT", - "dependencies": { - "buf-compare": "^1.0.0", - "is-error": "^2.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -9900,15 +9116,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -10195,18 +9402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-strict-equal": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", - "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", - "license": "MIT", - "dependencies": { - "core-assert": "^0.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -10422,12 +9617,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10969,60 +10158,16 @@ "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-compiler": { - "version": "19.1.0-rc.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz", - "integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/plugin-proposal-private-methods": "^7.18.6", - "hermes-parser": "^0.25.1", - "zod": "^3.22.4", - "zod-validation-error": "^3.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, - "peerDependencies": { - "eslint": ">=7" - } - }, - "node_modules/eslint-plugin-react-compiler/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", - "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=4" }, "peerDependencies": { - "zod": "^3.24.4" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "node_modules/eslint-plugin-react-hooks": { @@ -11479,45 +10624,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.2.0", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.4.1", - "xml-naming": "^0.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -11576,12 +10682,6 @@ "node": ">= 8" } }, - "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", - "license": "MIT" - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -12251,17 +11351,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/h3-js": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", - "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", - "license": "Apache-2.0", - "engines": { - "node": ">=4", - "npm": ">=3", - "yarn": ">=1.3.0" - } - }, "node_modules/happy-dom": { "version": "20.10.6", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", @@ -12382,23 +11471,6 @@ "set-cookie-parser": "^3.0.1" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/hono": { "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", @@ -12511,26 +11583,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -12540,24 +11592,6 @@ "node": ">= 4" } }, - "node_modules/image-size": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", - "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -12709,12 +11743,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -12810,12 +11838,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-error": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", - "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", - "license": "MIT" - }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -13195,18 +12217,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -13268,12 +12278,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -13532,18 +12536,6 @@ "node": ">=4.0" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/kdbush": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", @@ -13569,12 +12561,6 @@ "node": ">=6" } }, - "node_modules/ktx-parse": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", - "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", - "license": "MIT" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -13589,15 +12575,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -14046,15 +13023,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/long": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", - "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.6" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14082,13 +13050,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lz4js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", - "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", - "license": "ISC", - "optional": true - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -14218,17 +13179,6 @@ "node": ">= 0.4" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -15211,12 +14161,6 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -15229,12 +14173,6 @@ "node": ">=6" } }, - "node_modules/parquet-wasm": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/parquet-wasm/-/parquet-wasm-0.7.1.tgz", - "integrity": "sha512-fjEGpMApzt3mpI2pUxdRgQGu5G+s4nr0vm5xn43JO7jxdYzzu2fHrVrTHtfeEhtB6vfvTzJBz0WydDYzLWvszQ==", - "license": "MIT OR Apache-2.0" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -15290,21 +14228,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", - "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -15361,19 +14284,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pbf": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", - "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "ieee754": "^1.1.12", - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -15543,17 +14453,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15595,12 +14494,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -15979,21 +14872,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -16333,16 +15211,6 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -16370,12 +15238,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -16554,12 +15416,6 @@ "node": ">=0.10.0" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -16997,19 +15853,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -17118,12 +15961,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/snappyjs": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", - "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", - "license": "MIT" - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -17235,12 +16072,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -17296,15 +16127,6 @@ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -17520,21 +16342,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.1" - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -17558,22 +16365,6 @@ } } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -17695,28 +16486,6 @@ "dev": true, "license": "MIT" }, - "node_modules/texture-compressor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", - "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.10", - "image-size": "^0.7.4" - }, - "bin": { - "texture-compressor": "bin/texture-compressor.js" - } - }, - "node_modules/texture-compressor/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -17830,16 +16599,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -19895,21 +18654,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -20104,22 +18848,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-openapi": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/zod-openapi/-/zod-openapi-5.4.6.tgz", - "integrity": "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/samchungy/zod-openapi?sponsor=1" - }, - "peerDependencies": { - "zod": "^3.25.74 || ^4.0.0" - } - }, "node_modules/zod-to-json-schema": { "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", @@ -20128,13 +18856,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zstd-codec": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", - "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", - "license": "MIT", - "optional": true } } } diff --git a/package.json b/package.json index dbc51061..d5c778ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "insightflare", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "packageManager": "npm@11.15.0", @@ -18,12 +18,13 @@ "lint:fix": "eslint --max-warnings 0 . --fix", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,css}\" --log-level warn", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,css}\" --log-level warn", - "check:openapi": "npm run generate:openapi && npm run generate:skills && node scripts/check-openapi-contract.mjs && git diff --exit-code docs/openapi.json docs/openapi.yaml docs/skills.json", - "check:skills": "npm run generate:skills && node scripts/check-openapi-contract.mjs && git diff --exit-code docs/skills.json", - "check": "concurrently --kill-others-on-fail --prefix \"[{name}]\" --names \"Type,Fix,i18n,Test,Spec\" --prefix-colors \"blue,cyan,yellow,magenta,green\" \"npm run typecheck\" \"npm run lint:fix && npm run format\" \"npm run check:i18n\" \"npm run test\" \"npm run check:openapi && npm run check:skills\"", - "check:dry": "concurrently --kill-others-on-fail --prefix \"[{name}]\" --names \"Type,Lint,Format,i18n,Test,Spec\" --prefix-colors \"blue,cyan,green,yellow,magenta,white\" \"npm run typecheck\" \"npm run lint\" \"npm run format:check\" \"npm run check:i18n\" \"npm run test\" \"npm run check:openapi && npm run check:skills\"", + "check:openapi": "npm run generate:openapi && npm run generate:skills && tsx scripts/check-openapi-contract.ts && git diff --exit-code docs/openapi.json docs/openapi.yaml docs/skills.json", + "check:skills": "npm run generate:skills && tsx scripts/check-openapi-contract.ts && git diff --exit-code docs/skills.json", + "check": "tsx scripts/check.ts", + "check:verbose": "tsx scripts/check.ts --verbose", + "check:dry": "npm run check", "prepare": "npm run build:sdk && husky", - "ensure:ast-grep": "node scripts/ensure-ast-grep-binding.mjs", + "ensure:ast-grep": "tsx scripts/ensure-ast-grep-binding.ts", "cf:build:raw": "npm run ensure:ast-grep && opennextjs-cloudflare build", "cf:build": "npm run build:pre:local && npm run cf:build:raw", "cf:preview": "npm run cf:build && wrangler dev --config wrangler.toml", @@ -58,26 +59,23 @@ }, "dependencies": { "@deck.gl/core": "^9.3.2", - "@deck.gl/geo-layers": "^9.3.2", "@deck.gl/layers": "^9.3.2", "@deck.gl/mapbox": "^9.3.2", - "@deck.gl/react": "^9.3.2", "@iconify-json/flagpack": "^1.2.7", "@iconify/react": "^6.0.2", "@noble/hashes": "^2.2.0", "@number-flow/react": "^0.6.0", "@remixicon/react": "^4.9.0", - "apache-arrow": "21.1.0", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "hono": "^4.12.27", "i18n-iso-countries": "^7.14.0", "maplibre-gl": "^5.24.0", "motion": "^12.40.0", "next": "^16.2.6", "next-themes": "^0.4.6", "overlayscrollbars": "^2.16.0", - "parquet-wasm": "^0.7.1", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-day-picker": "^9.14.0", @@ -102,13 +100,11 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.7", - "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "~9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.5.0", @@ -125,8 +121,7 @@ "vitest": "^4.1.7", "wrangler": "^4.94.0", "yaml": "^2.9.0", - "yaml-loader": "^0.9.0", - "zod-openapi": "^5.4.6" + "yaml-loader": "^0.9.0" }, "optionalDependencies": { "@ast-grep/napi-linux-x64-gnu": "0.42.3" diff --git a/prettier.config.js b/prettier.config.js index d63847af..a2a593e8 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,6 +1,13 @@ /** @type {import("prettier").Config} */ const config = { endOfLine: "auto", + semi: true, + singleQuote: false, + trailingComma: "all", + tabWidth: 2, + printWidth: 80, + bracketSpacing: true, + arrowParens: "always", }; export default config; diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 832015be..a9bafb61 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index b4e54cd3..0f4b26bc 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 89d8a0a5..dbe25789 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 2c0b6ae1..ef49aa67 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index d0e1d3d2..804106f2 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icon-1024.png b/public/icon-1024.png new file mode 100644 index 00000000..35f265dd Binary files /dev/null and b/public/icon-1024.png differ diff --git a/scripts/build-tracker-sdk.ts b/scripts/build-tracker-sdk.ts index e09389a2..f8540924 100644 --- a/scripts/build-tracker-sdk.ts +++ b/scripts/build-tracker-sdk.ts @@ -6,23 +6,18 @@ * Placeholder strings (__IF_*__) survive minification and are replaced at serve time. */ import * as esbuild from "esbuild"; -import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { writeFileSync } from "fs"; import { dirname, resolve } from "path"; -import Rlog from "rlog-js"; import { fileURLToPath } from "url"; +import { createScriptLogger } from "./shared/logger"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); const entry = resolve(root, "src/tracker/sdk.ts"); -const logsDir = resolve(root, "logs"); -if (!existsSync(logsDir)) { - mkdirSync(logsDir, { recursive: true }); -} - -const rlog = new Rlog({ - logFilePath: resolve(logsDir, "build-sdk.log"), - enableColorfulOutput: true, +const rlog = createScriptLogger({ + logFile: "build-sdk.log", }); const commonOpts: esbuild.BuildOptions = { diff --git a/scripts/check-env.ts b/scripts/check-env.ts index fc0a2745..073a71e9 100644 --- a/scripts/check-env.ts +++ b/scripts/check-env.ts @@ -3,9 +3,9 @@ import { randomBytes } from "node:crypto"; -import Rlog from "rlog-js"; +import { createScriptLogger } from "./shared/logger"; -const rlog = new Rlog(); +const rlog = createScriptLogger(); // 计算香农熵 function calculateShannonEntropy(str: string): number { diff --git a/scripts/check-openapi-contract.mjs b/scripts/check-openapi-contract.ts similarity index 77% rename from scripts/check-openapi-contract.mjs rename to scripts/check-openapi-contract.ts index 981ad431..854d88c5 100644 --- a/scripts/check-openapi-contract.mjs +++ b/scripts/check-openapi-contract.ts @@ -1,11 +1,17 @@ -#!/usr/bin/env node +#!/usr/bin/env tsx +/* eslint-disable @typescript-eslint/ban-ts-comment -- legacy contract walker migrated from JS; keep runtime logic stable while script structure is unified. */ +// @ts-nocheck import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import process from "node:process"; + +import { createScriptLogger } from "./shared/logger"; const root = resolve(import.meta.dirname, ".."); const openapiPath = resolve(root, "docs", "openapi.json"); const skillsPath = resolve(root, "docs", "skills.json"); +const rlog = createScriptLogger(); const openapi = JSON.parse(readFileSync(openapiPath, "utf8")); const skills = JSON.parse(readFileSync(skillsPath, "utf8")); @@ -61,6 +67,10 @@ function refName(value) { return String(value.$ref).split("/").at(-1) ?? null; } +function dereferenceParameter(parameter) { + return dereference(parameter); +} + function responseSchemas(operation) { const schemas = []; for (const response of Object.values(operation.responses ?? {})) { @@ -163,7 +173,7 @@ for (const [path, pathItem] of Object.entries(openapi.paths ?? {})) { ...(pathItem.parameters ?? []), ...(operation.parameters ?? []), ] - .map(dereference) + .map(dereferenceParameter) .filter(Boolean); for (const parameter of parameters) { if (parameter.name === "queryName") { @@ -219,6 +229,28 @@ for (const [path, pathItem] of Object.entries(openapi.paths ?? {})) { `${key} /api/v1 success response must not use GenericObjectResponse`, ); } + + if (operation.responses?.["429"]) { + issues.push(`${key} must not declare 429 as a stable origin response`); + } + + if (!Object.prototype.hasOwnProperty.call(operation, "x-required-scopes")) { + issues.push(`${key} is missing x-required-scopes`); + } else if (!Array.isArray(operation["x-required-scopes"])) { + issues.push(`${key} x-required-scopes must be an array`); + } else if ( + !(operation.security && operation.security.length === 0) && + operation["x-required-scopes"].length === 0 && + path.startsWith("/api/v1") && + ![ + "/api/v1", + "/api/v1/token", + "/api/v1/token/check", + "/api/v1/capabilities", + ].includes(path) + ) { + issues.push(`${key} authenticated operation should declare a scope`); + } } } @@ -259,6 +291,69 @@ for (const forbidden of [ } } +if (!openapi.info?.description?.includes("ISO 8601 date-time strings")) { + issues.push("Top-level description must describe ISO 8601 timestamps"); +} +if ( + !openapi.info?.description?.includes( + "outside the standard API error envelope", + ) +) { + issues.push( + "Top-level description must explain upstream 429 as non-contract", + ); +} +if (!Array.isArray(openapi["x-possible-upstream-responses"])) { + issues.push("OpenAPI must expose x-possible-upstream-responses"); +} + +for (const [name, parameter] of Object.entries( + openapi.components?.parameters ?? {}, +)) { + if (["FromQueryParam", "ToQueryParam"].includes(name)) { + if ( + parameter.schema?.type !== "string" || + parameter.schema?.format !== "date-time" + ) { + issues.push(`${name} must be an ISO 8601 date-time string parameter`); + } + if (/unix|millisecond/i.test(parameter.description ?? "")) { + issues.push(`${name} description must not mention Unix milliseconds`); + } + } +} + +for (const name of [ + "SiteIdPathParam", + "FromQueryParam", + "ToQueryParam", + "PresetQueryParam", + "TimeZoneQueryParam", + "MetricsQueryParam", + "FilterQueryParam", + "LimitQueryParam", + "CursorQueryParam", +]) { + if (!openapi.components?.parameters?.[name]) { + issues.push(`Missing reusable parameter ${name}`); + } +} + +const visitorParam = openapi.components?.parameters?.VisitorIdPathParam; +if (visitorParam?.schema?.format === "uuid") { + issues.push("VisitorIdPathParam must not require uuid format"); +} +const sessionParam = openapi.components?.parameters?.SessionIdPathParam; +if (sessionParam?.schema?.format === "uuid") { + issues.push("SessionIdPathParam must not require uuid format"); +} + +const complexFilterValue = + openapi.components?.schemas?.ComplexFilter?.properties?.value; +if (!Array.isArray(complexFilterValue?.oneOf)) { + issues.push("ComplexFilter.value must define a constrained oneOf schema"); +} + const collect = openapi.paths?.["/collect"]?.post; if (collect?.responses?.["429"]) { issues.push("/collect must not declare a 429 response"); @@ -367,14 +462,14 @@ if (skills.endpoints !== undefined) { } if (issues.length > 0) { - console.error("OpenAPI contract check failed:"); + rlog.error("OpenAPI contract check failed:"); for (const issue of issues) { - console.error(`- ${issue}`); + rlog.error(`- ${issue}`); } process.exit(1); } -console.log( +rlog.success( `OpenAPI contract check passed (${operations.length} operations, ${ Object.keys(openapi.components?.schemas ?? {}).length } schemas).`, diff --git a/scripts/check-runner/cli.ts b/scripts/check-runner/cli.ts new file mode 100644 index 00000000..262b2597 --- /dev/null +++ b/scripts/check-runner/cli.ts @@ -0,0 +1,220 @@ +import { spawn } from "node:child_process"; +import process from "node:process"; + +import { createScriptLogger } from "../shared/logger"; + +interface CheckStep { + name: string; + args: string[]; +} + +interface CheckTask { + name: string; + steps: CheckStep[]; +} + +interface StepResult { + ok: boolean; + output: string; + code?: number | null; + signal?: NodeJS.Signals | null; +} + +interface TaskResult extends StepResult { + name: string; + failedStep?: string; +} + +export const rlog = createScriptLogger({ + silent: true, +}); + +const tasks: CheckTask[] = [ + { + name: "Quality", + steps: [ + { + name: "Format", + args: ["run", "format:check"], + }, + { + name: "Lint", + args: ["run", "lint"], + }, + { + name: "Translations", + args: ["run", "check:i18n"], + }, + { + name: "Typecheck", + args: ["run", "typecheck"], + }, + ], + }, + { + name: "Coverage", + steps: [ + { + name: "Coverage", + args: ["run", "test:coverage"], + }, + ], + }, + { + name: "Spec", + steps: [ + { + name: "OpenAPI spec", + args: ["run", "check:openapi"], + }, + { + name: "Skills spec", + args: ["run", "check:skills"], + }, + ], + }, + { + name: "Build", + steps: [ + { + name: "Build", + args: ["run", "ci:build"], + }, + ], + }, +]; + +function npmSpawnCommand(args: string[]): { command: string; args: string[] } { + if (process.platform !== "win32") { + return { + command: "npm", + args, + }; + } + + return { + command: process.env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", ["npm", ...args].join(" ")], + }; +} + +function runStep(step: CheckStep, verbose: boolean): Promise { + return new Promise((resolve) => { + const command = npmSpawnCommand(step.args); + const child = spawn(command.command, command.args, { + cwd: process.cwd(), + env: process.env, + stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"], + }); + + let output = ""; + + if (!verbose) { + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + } + + child.on("error", (error: Error) => { + resolve({ + ok: false, + output: error.stack || error.message, + }); + }); + + child.on("close", (code, signal) => { + resolve({ + ok: code === 0, + output, + code, + signal, + }); + }); + }); +} + +async function runTask(task: CheckTask, verbose: boolean): Promise { + rlog.info(`Running ${task.name}`); + + for (const step of task.steps) { + if (verbose && task.steps.length > 1) { + rlog.info(`Running ${task.name} / ${step.name}`); + } + + const result = await runStep(step, verbose); + if (!result.ok) { + return { + ...result, + name: task.name, + failedStep: step.name, + }; + } + } + + rlog.success(`Passed ${task.name}`); + return { + ok: true, + output: "", + code: 0, + name: task.name, + }; +} + +function parseArgs(argv: string[]): { verbose: boolean } { + const knownArgs = new Set(["--verbose", "--help", "-h"]); + const unknownArgs = argv.filter((arg) => !knownArgs.has(arg)); + + if (argv.includes("--help") || argv.includes("-h")) { + rlog.info("Usage: tsx scripts/check.ts [--verbose]"); + rlog.info( + "Runs the same quality, coverage, spec, and build checks used by CI.", + ); + process.exit(0); + } + + if (unknownArgs.length > 0) { + rlog.error(`Unknown option: ${unknownArgs.join(", ")}`); + rlog.info("Usage: tsx scripts/check.ts [--verbose]"); + process.exit(1); + } + + return { + verbose: argv.includes("--verbose"), + }; +} + +export async function runCli(argv = process.argv.slice(2)): Promise { + const { verbose } = parseArgs(argv); + const results = await Promise.all( + tasks.map((task) => runTask(task, verbose)), + ); + const failures = results.filter((result) => !result.ok); + + for (const result of failures) { + const name = result.failedStep + ? `${result.name} / ${result.failedStep}` + : result.name; + rlog.error(`Failed ${name}`); + if (result.signal) { + rlog.error(`Signal: ${result.signal}`); + } else if (typeof result.code === "number") { + rlog.error(`Exit code: ${result.code}`); + } + + const trimmedOutput = result.output.trim(); + if (trimmedOutput) { + rlog.error(["--- output ---", trimmedOutput].join("\n")); + } + + rlog.error(""); + } + + if (failures.length > 0) { + process.exit(1); + } + + rlog.success("All checks passed"); +} diff --git a/scripts/check.ts b/scripts/check.ts new file mode 100644 index 00000000..a4d62533 --- /dev/null +++ b/scripts/check.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env tsx + +import process from "node:process"; + +import { rlog, runCli } from "./check-runner/cli"; + +runCli().catch((error: unknown) => { + const message = + error instanceof Error ? error.stack || error.message : String(error); + rlog.error(message); + process.exitCode = 1; +}); diff --git a/scripts/ensure-ast-grep-binding.mjs b/scripts/ensure-ast-grep-binding.ts similarity index 63% rename from scripts/ensure-ast-grep-binding.mjs rename to scripts/ensure-ast-grep-binding.ts index 7c71b9a9..4570959c 100644 --- a/scripts/ensure-ast-grep-binding.mjs +++ b/scripts/ensure-ast-grep-binding.ts @@ -1,19 +1,23 @@ +#!/usr/bin/env tsx + import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import process from "node:process"; -const require = createRequire(import.meta.url); +import { createScriptLogger } from "./shared/logger"; -function log(message) { - console.log(`[ensure-ast-grep] ${message}`); -} +const require = createRequire(import.meta.url); +const rlog = createScriptLogger(); -function readAstGrepVersion() { +function readAstGrepVersion(): string { const pkgPath = "node_modules/@ast-grep/napi/package.json"; if (!existsSync(pkgPath)) { throw new Error("Missing node_modules/@ast-grep/napi/package.json"); } - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { + version?: unknown; + }; const version = String(pkg.version || "").trim(); if (!version) { throw new Error("Cannot read @ast-grep/napi version"); @@ -21,7 +25,7 @@ function readAstGrepVersion() { return version; } -function canLoadAstGrep() { +function canLoadAstGrep(): boolean { try { require("@ast-grep/napi"); return true; @@ -30,7 +34,7 @@ function canLoadAstGrep() { } } -function runNpmInstall(pkgWithVersion) { +function runNpmInstall(pkgWithVersion: string): boolean { const result = spawnSync( process.platform === "win32" ? "npm.cmd" : "npm", ["install", "--no-save", pkgWithVersion], @@ -39,16 +43,16 @@ function runNpmInstall(pkgWithVersion) { return result.status === 0; } -function targetBindingPackage(version) { +function targetBindingPackage(version: string): string | null { if (process.platform === "linux" && process.arch === "x64") { return `@ast-grep/napi-linux-x64-gnu@${version}`; } return null; } -function main() { +export function runCli(): void { if (canLoadAstGrep()) { - log("native binding already available"); + rlog.success("ast-grep native binding already available"); return; } @@ -60,12 +64,19 @@ function main() { ); } - log(`binding missing, installing ${pkg}`); + rlog.info(`ast-grep binding missing, installing ${pkg}`); const ok = runNpmInstall(pkg); if (!ok || !canLoadAstGrep()) { throw new Error(`Failed to install working binding package: ${pkg}`); } - log("binding repaired"); + rlog.success("ast-grep binding repaired"); } -main(); +try { + runCli(); +} catch (error: unknown) { + const message = + error instanceof Error ? error.stack || error.message : String(error); + rlog.error(message); + process.exitCode = 1; +} diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 05a0502b..0cdeb877 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1,10 +1,20 @@ #!/usr/bin/env tsx import { execSync } from "child_process"; -import { writeFileSync } from "fs"; +import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; import YAML from "yaml"; +import { createScriptLogger } from "./shared/logger"; + +const ROOT = resolve(import.meta.dirname, ".."); +const rlog = createScriptLogger(); + +function getAppVersion(): string { + const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")); + return pkg.version; +} + type HttpMethod = "get" | "post" | "patch" | "delete"; interface Operation { @@ -16,11 +26,14 @@ interface Operation { parameters?: unknown[]; requestBody?: unknown; responses: Record; + "x-required-scopes"?: string[]; } interface OpenAPISpec { openapi: string; info: Record; + externalDocs?: Record; + "x-possible-upstream-responses"?: number[]; servers: Array<{ url: string; description: string }>; security: Array>; tags: Array<{ name: string; description: string }>; @@ -42,6 +55,10 @@ function ref(name: string) { return { $ref: `#/components/schemas/${name}` }; } +function parameterRef(name: string) { + return { $ref: `#/components/parameters/${name}` }; +} + function response(description: string, schema: string, example?: unknown) { return { description, @@ -172,8 +189,35 @@ function errorResponses(...codes: string[]) { return map; } +function requiredScopesForOperation(input: Operation): string[] { + if (input.security && input.security.length === 0) return []; + if (input["x-required-scopes"]) return input["x-required-scopes"]; + + const [tag] = input.tags; + const isWrite = /^(create|update|delete)/i.test(input.operationId); + + if (tag === "Analytics" || tag === "Events" || tag === "Visitors") { + return ["analytics:read"]; + } + if (tag === "Sessions" || tag === "Performance" || tag === "Realtime") { + return ["analytics:read"]; + } + if (tag === "Batch") return ["analytics:read"]; + if (tag === "Sites") return isWrite ? ["site:write"] : ["site:read"]; + if (tag === "Settings") { + return isWrite ? ["site_config:write"] : ["site_config:read"]; + } + if (tag === "Funnels") return isWrite ? ["site:write"] : ["analytics:read"]; + if (tag === "Team") return ["site:read"]; + + return []; +} + function op(input: Operation): Operation { - return input; + return { + ...input, + "x-required-scopes": requiredScopesForOperation(input), + }; } function queryParam(name: string, schema: unknown, description: string) { @@ -181,95 +225,25 @@ function queryParam(name: string, schema: unknown, description: string) { } function timeParams(includeInterval = false) { - const defaultHint = - " If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."; return [ - queryParam( - "from", - { type: "string", format: "date-time" }, - `Inclusive ISO 8601 start time.${defaultHint}`, - ), - queryParam( - "to", - { type: "string", format: "date-time" }, - `Exclusive ISO 8601 end time.${defaultHint}`, - ), - queryParam( - "preset", - ref("Preset"), - `Named time range preset. Mutually exclusive with from and to.${defaultHint}`, - ), - queryParam( - "timeZone", - { type: "string", maxLength: 80, default: "UTC" }, - "IANA time zone used to resolve presets. Defaults to UTC.", - ), - ...(includeInterval - ? [ - queryParam( - "interval", - { - type: "string", - enum: ["minute", "hour", "day", "week", "month"], - default: "day", - }, - "Time bucket granularity.", - ), - ] - : []), + parameterRef("FromQueryParam"), + parameterRef("ToQueryParam"), + parameterRef("PresetQueryParam"), + parameterRef("TimeZoneQueryParam"), + ...(includeInterval ? [parameterRef("IntervalQueryParam")] : []), ]; } function filterParam() { - return { - name: "filter", - in: "query", - style: "deepObject", - explode: true, - schema: ref("FilterObject"), - description: "Simple equality filters as filter[field]=value.", - }; + return parameterRef("FilterQueryParam"); } function metricParam() { - return { - name: "metrics", - in: "query", - style: "form", - explode: false, - schema: { - type: "array", - items: { - type: "string", - enum: [ - "views", - "sessions", - "visitors", - "bounces", - "bounceRate", - "avgDurationMs", - "viewsPerSession", - "events", - ], - }, - }, - description: "Comma-separated metrics to include.", - }; + return parameterRef("MetricsQueryParam"); } function cursorParams() { - return [ - queryParam( - "limit", - { type: "integer", minimum: 1, maximum: 1000, default: 100 }, - "Maximum number of results.", - ), - queryParam( - "cursor", - { type: "string", maxLength: 512 }, - "Opaque pagination cursor from the previous response.", - ), - ]; + return [parameterRef("LimitQueryParam"), parameterRef("CursorQueryParam")]; } function sortParam() { @@ -625,7 +599,7 @@ function buildSchemas(): Record { ComplexFilter: { type: "object", description: - "Advanced filter rule for explore and search endpoints. Operators: eq equals; neq does not equal; in is one of; notIn is not one of; contains includes substring; startsWith/endsWith match string edges; gt/gte/lt/lte compare ordered values; exists/notExists ignore value.", + "Advanced filter rule for explore and search endpoints. For eq, neq, contains, startsWith, and endsWith, use a scalar value. For in and notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO 8601 date-time string depending on the field. For exists and notExists, value may be omitted.", required: ["field", "op"], properties: { field: { @@ -653,29 +627,57 @@ function buildSchemas(): Record { "notExists", ], }, - value: {}, + value: { + oneOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { + type: "array", + items: { + oneOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + ], + }, + }, + ], + }, }, }, MetricDefinition: { type: "object", description: "Metric available for analytics queries.", - required: ["key", "label", "type", "description"], + required: ["id", "key", "label", "type", "description"], properties: { + id: { type: "string" }, key: { type: "string" }, label: { type: "string" }, - type: { type: "string", enum: ["integer", "rate", "duration_ms"] }, description: { type: "string" }, + unit: { type: "string", enum: ["count", "ratio", "milliseconds"] }, + type: { type: "string", enum: ["integer", "number", "rate"] }, + aggregation: { + type: "string", + enum: ["sum", "average", "ratio", "derived"], + }, + filterable: { type: "boolean" }, + sortable: { type: "boolean" }, }, }, DimensionDefinition: { type: "object", description: "Dimension available for analytics breakdowns and filters.", - required: ["key", "label", "type"], + required: ["id", "key", "label", "type"], properties: { + id: { type: "string" }, key: { type: "string" }, label: { type: "string" }, - type: { type: "string" }, description: { type: "string" }, + type: { type: "string" }, + filterable: { type: "boolean" }, + groupable: { type: "boolean" }, + sortable: { type: "boolean" }, }, }, SiteAccess: { @@ -885,17 +887,39 @@ function buildSchemas(): Record { properties: { name: { type: "string", minLength: 1, maxLength: 120 }, domain: { type: "string", minLength: 1, maxLength: 255 }, - sharing: ref("SharingSettings"), + publicEnabled: { + type: "boolean", + default: false, + description: "Whether the public sharing link is enabled.", + }, + publicSlug: { + type: "string", + maxLength: 120, + description: + "Optional public sharing slug when publicEnabled is true.", + }, }, + additionalProperties: false, }, SiteUpdateInput: { type: "object", - description: "Partial update for site metadata and sharing settings.", + description: + "Partial update for site metadata and public sharing input fields.", properties: { name: { type: "string", minLength: 1, maxLength: 120 }, domain: { type: "string", minLength: 1, maxLength: 255 }, - sharing: ref("SharingSettings"), + publicEnabled: { + type: "boolean", + description: "Whether the public sharing link is enabled.", + }, + publicSlug: { + type: "string", + maxLength: 120, + description: + "Optional public sharing slug when publicEnabled is true.", + }, }, + additionalProperties: false, }, SiteResponse: envelope(ref("Site")), SiteListResponse: listEnvelope(ref("Site")), @@ -942,7 +966,12 @@ function buildSchemas(): Record { respectDoNotTrack: { type: "boolean" }, anonymizeIp: { type: "boolean" }, euMode: { type: "boolean" }, - visitorTokenMode: { type: "string", enum: ["daily"] }, + visitorTokenMode: { + type: "string", + enum: ["daily", "weekly", "monthly", "session", "none"], + description: + "Visitor token rotation mode. The current runtime behavior uses daily tokens; additional values are reserved for compatible future configuration.", + }, dataRetentionDays: { type: "integer", minimum: 1 }, }, }, @@ -1346,6 +1375,21 @@ function buildSchemas(): Record { value: { description: "Comparison value. Required unless op is exists or notExists.", + oneOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { + type: "array", + items: { + oneOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + ], + }, + }, + ], }, }, additionalProperties: false, @@ -1528,17 +1572,16 @@ function buildSchemas(): Record { }, FunnelAnalysisRequest: { type: "object", - description: "Request for ad-hoc funnel analysis.", + description: + "Request for ad-hoc funnel analysis. Use query parameters (from, to, preset, timeZone) for time range.", required: ["steps"], properties: { - timeRange: ref("TimeRangeInput"), steps: { type: "array", minItems: 2, maxItems: 10, items: ref("FunnelStepInput"), }, - filters: { type: "array", items: ref("ComplexFilter") }, }, additionalProperties: false, }, @@ -1850,12 +1893,13 @@ function buildSchemas(): Record { } function buildPaths(): OpenAPISpec["paths"] { - const siteParam = { $ref: "#/components/parameters/siteId" }; - const dimensionParam = { $ref: "#/components/parameters/dimension" }; - const eventNameParam = { $ref: "#/components/parameters/eventName" }; - const eventIdParam = { $ref: "#/components/parameters/eventId" }; - const visitorIdParam = { $ref: "#/components/parameters/visitorId" }; - const sessionIdParam = { $ref: "#/components/parameters/sessionId" }; + const siteParam = parameterRef("SiteIdPathParam"); + const dimensionParam = parameterRef("DimensionPathParam"); + const eventNameParam = parameterRef("EventNamePathParam"); + const eventIdParam = parameterRef("EventIdPathParam"); + const visitorIdParam = parameterRef("VisitorIdPathParam"); + const sessionIdParam = parameterRef("SessionIdPathParam"); + const funnelIdParam = parameterRef("FunnelIdPathParam"); return { "/healthz": { @@ -2243,7 +2287,8 @@ function buildPaths(): OpenAPISpec["paths"] { get: op({ operationId: "getAnalyticsCrossBreakdown", summary: "Get analytics cross breakdown", - description: "Returns a two-dimensional analytics breakdown.", + description: + "Returns a two-dimensional analytics breakdown. Supports page, referrer, UTM, client, and geo dimensions. Session and event dimensions are not supported.", tags: ["Analytics"], parameters: [ ...timeParams(), @@ -2251,12 +2296,12 @@ function buildPaths(): OpenAPISpec["paths"] { queryParam( "primary", { type: "string", maxLength: 120 }, - "Primary dimension.", + "Primary dimension (e.g. client.browser, geo.country, page.path).", ), queryParam( "secondary", { type: "string", maxLength: 120 }, - "Secondary dimension.", + "Secondary dimension (must differ from primary).", ), queryParam( "metric", @@ -2462,6 +2507,14 @@ function buildPaths(): OpenAPISpec["paths"] { { type: "string", maxLength: 240 }, "Field path.", ), + queryParam( + "fieldValueType", + { + type: "string", + enum: ["string", "number", "boolean", "null", "object", "array"], + }, + "Expected value type for the field.", + ), queryParam( "search", { type: "string", maxLength: 160 }, @@ -2624,7 +2677,7 @@ function buildPaths(): OpenAPISpec["paths"] { }), }, "/api/v1/sites/{siteId}/funnels/analysis": { - parameters: [siteParam], + parameters: [siteParam, ...timeParams()], post: op({ operationId: "analyzeFunnel", summary: "Analyze funnel", @@ -2638,7 +2691,7 @@ function buildPaths(): OpenAPISpec["paths"] { }), }, "/api/v1/sites/{siteId}/funnels/{funnelId}": { - parameters: [siteParam, { $ref: "#/components/parameters/funnelId" }], + parameters: [siteParam, funnelIdParam], get: op({ operationId: "getFunnel", summary: "Get funnel", @@ -2672,7 +2725,7 @@ function buildPaths(): OpenAPISpec["paths"] { }), }, "/api/v1/sites/{siteId}/funnels/{funnelId}/analysis": { - parameters: [siteParam, { $ref: "#/components/parameters/funnelId" }], + parameters: [siteParam, funnelIdParam], get: op({ operationId: "getFunnelAnalysis", summary: "Get funnel analysis", @@ -2893,7 +2946,7 @@ function responseExampleFor(schemaName: string | null, operationId: string) { const examples: Record = { HealthResponse: { status: "healthy", timestamp: sampleGeneratedAt }, RootDiscoveryResponse: success({ - version: "1.0.0", + version: getAppVersion(), service: "InsightFlare Analytics API", links: { self: "/api/v1", @@ -2921,7 +2974,7 @@ function responseExampleFor(schemaName: string | null, operationId: string) { ], }), CapabilitiesResponse: success({ - apiVersion: "1.0.0", + apiVersion: getAppVersion(), features: { sites: true, tracking: true, @@ -2995,21 +3048,49 @@ function responseExampleFor(schemaName: string | null, operationId: string) { AnalyticsSchemaResponse: success({ metrics: [ { + id: "views", key: "views", label: "Views", type: "integer", description: "Total page views.", + unit: "count", + aggregation: "sum", + filterable: false, + sortable: true, }, { + id: "bounceRate", key: "bounceRate", label: "Bounce rate", type: "rate", description: "Single-page session rate as a 0-1 ratio.", + unit: "ratio", + aggregation: "ratio", + filterable: false, + sortable: true, }, ], dimensions: [ - { key: "page.path", label: "Page path", type: "string" }, - { key: "geo.country", label: "Country", type: "string" }, + { + id: "page.path", + key: "page.path", + label: "Page path", + description: "Normalized page path from the tracked URL.", + type: "string", + filterable: true, + groupable: true, + sortable: true, + }, + { + id: "geo.country", + key: "geo.country", + label: "Country", + description: "Visitor country inferred from request metadata.", + type: "string", + filterable: true, + groupable: true, + sortable: true, + }, ], filters: ["page.path", "geo.country"], operators: ["eq", "in", "startsWith"], @@ -3178,7 +3259,8 @@ function requestExamplesFor(schemaName: string | null) { value: { name: "Example Blog", domain: "example.com", - sharing: { publicEnabled: true, publicSlug: "example-blog" }, + publicEnabled: true, + publicSlug: "example-blog", }, }, }, @@ -3187,7 +3269,7 @@ function requestExamplesFor(schemaName: string | null) { summary: "Update a site", value: { name: "Example Blog", - sharing: { publicEnabled: false, publicSlug: null }, + publicEnabled: false, }, }, }, @@ -3266,9 +3348,7 @@ function requestExamplesFor(schemaName: string | null) { default: { summary: "Analyze an ad-hoc funnel", value: { - timeRange: sampleTimeRange, steps: funnelExample.steps, - filters: [{ field: "geo.country", op: "in", value: ["US", "CA"] }], }, }, }, @@ -3378,8 +3458,8 @@ function buildSpec(): OpenAPISpec { info: { title: "InsightFlare API", description: - "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All API times are ISO 8601 strings and analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.", - version: "1.0.0", + "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.", + version: getAppVersion(), contact: { name: "InsightFlare", url: "https://github.com/ravelloh/InsightFlare", @@ -3389,6 +3469,11 @@ function buildSpec(): OpenAPISpec { url: "https://github.com/ravelloh/InsightFlare/blob/main/LICENSE", }, }, + externalDocs: { + description: "InsightFlare API documentation", + url: "https://insight.ravelloh.com/docs", + }, + "x-possible-upstream-responses": [429], servers: [ { url: "https://insight.ravelloh.com", description: "Production" }, ], @@ -3425,55 +3510,136 @@ function buildSpec(): OpenAPISpec { }, }, parameters: { - siteId: { + SiteIdPathParam: { name: "siteId", in: "path", required: true, schema: { type: "string", format: "uuid" }, description: "Site UUID.", }, - dimension: { + DimensionPathParam: { name: "dimension", in: "path", required: true, schema: { type: "string", maxLength: 120 }, description: "Stable analytics dimension key.", }, - eventName: { + EventNamePathParam: { name: "eventName", in: "path", required: true, schema: { type: "string", maxLength: 120 }, description: "Event name.", }, - eventId: { + EventIdPathParam: { name: "eventId", in: "path", required: true, schema: { type: "string", format: "uuid" }, description: "Event UUID.", }, - visitorId: { + VisitorIdPathParam: { name: "visitorId", in: "path", required: true, - schema: { type: "string", format: "uuid" }, - description: "Visitor UUID.", + schema: { type: "string", maxLength: 160 }, + description: "Opaque visitor identifier.", }, - sessionId: { + SessionIdPathParam: { name: "sessionId", in: "path", required: true, - schema: { type: "string", format: "uuid" }, - description: "Session UUID.", + schema: { type: "string", maxLength: 160 }, + description: "Opaque session identifier.", }, - funnelId: { + FunnelIdPathParam: { name: "funnelId", in: "path", required: true, schema: { type: "string", format: "uuid" }, description: "Funnel UUID.", }, + FromQueryParam: { + name: "from", + in: "query", + schema: { type: "string", format: "date-time" }, + description: + "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.", + }, + ToQueryParam: { + name: "to", + in: "query", + schema: { type: "string", format: "date-time" }, + description: + "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.", + }, + PresetQueryParam: { + name: "preset", + in: "query", + schema: ref("Preset"), + description: + "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.", + }, + TimeZoneQueryParam: { + name: "timeZone", + in: "query", + schema: { type: "string", maxLength: 80, default: "UTC" }, + description: + "IANA time zone used to resolve presets. Defaults to UTC.", + }, + IntervalQueryParam: { + name: "interval", + in: "query", + schema: { + type: "string", + enum: ["minute", "hour", "day", "week", "month"], + default: "day", + }, + description: "Time bucket granularity.", + }, + MetricsQueryParam: { + name: "metrics", + in: "query", + style: "form", + explode: false, + schema: { + type: "array", + items: { + type: "string", + enum: [ + "views", + "sessions", + "visitors", + "bounces", + "bounceRate", + "avgDurationMs", + "viewsPerSession", + "events", + ], + }, + }, + description: "Comma-separated metrics to include.", + }, + FilterQueryParam: { + name: "filter", + in: "query", + style: "deepObject", + explode: true, + schema: ref("FilterObject"), + description: "Simple equality filters as filter[field]=value.", + }, + LimitQueryParam: { + name: "limit", + in: "query", + schema: { type: "integer", minimum: 1, maximum: 1000, default: 100 }, + description: "Maximum number of results.", + }, + CursorQueryParam: { + name: "cursor", + in: "query", + schema: { type: "string", maxLength: 512 }, + description: "Opaque pagination cursor from the previous response.", + }, }, responses: { BadRequest: { description: "Bad request", ...errorContent }, @@ -3506,8 +3672,8 @@ function main() { // Files are valid even if formatting fails. } - console.log(`Generated ${yamlPath}`); - console.log(`Generated ${jsonPath}`); + rlog.success(`Generated ${yamlPath}`); + rlog.success(`Generated ${jsonPath}`); } main(); diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts index 39115065..7873fc35 100644 --- a/scripts/generate-skills.ts +++ b/scripts/generate-skills.ts @@ -1,18 +1,26 @@ #!/usr/bin/env tsx import { execSync } from "child_process"; -import { writeFileSync } from "fs"; +import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; +import { createScriptLogger } from "./shared/logger"; + const ROOT = resolve(import.meta.dirname, ".."); const OUTPUT_PATH = resolve(ROOT, "docs/skills.json"); +const rlog = createScriptLogger(); + +function getAppVersion(): string { + const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")); + return pkg.version; +} function generate() { const manifest = { api: "InsightFlare Analytics API", - version: "1.0.0", + version: getAppVersion(), description: "Privacy-focused web analytics platform.", - baseUrl: "https://insight.ravelloh.com", + baseUrl: "${baseUrl}", openapiUrl: "/.well-known/openapi.json", discovery: { root: "/api/v1", @@ -115,7 +123,7 @@ function generate() { } catch { // File remains valid JSON if formatting fails. } - console.log(`Generated ${OUTPUT_PATH}`); + rlog.success(`Generated ${OUTPUT_PATH}`); } generate(); diff --git a/scripts/i18n-check/logger.ts b/scripts/i18n-check/logger.ts index 52aeb796..a90dac24 100644 --- a/scripts/i18n-check/logger.ts +++ b/scripts/i18n-check/logger.ts @@ -1,16 +1,5 @@ -import standardFs from "node:fs"; -import path from "node:path"; +import { createScriptLogger } from "../shared/logger"; -import Rlog from "rlog-js"; - -import { ROOT_DIR } from "./paths"; - -const logsDir = path.join(ROOT_DIR, "logs"); -if (!standardFs.existsSync(logsDir)) { - standardFs.mkdirSync(logsDir, { recursive: true }); -} - -export const rlog = new Rlog({ - logFilePath: path.join(logsDir, "i18n.log"), - enableColorfulOutput: true, +export const rlog = createScriptLogger({ + logFile: "i18n.log", }); diff --git a/scripts/prebuild.ts b/scripts/prebuild.ts index b4a20f1b..118e3a4a 100644 --- a/scripts/prebuild.ts +++ b/scripts/prebuild.ts @@ -2,21 +2,14 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import Rlog from "rlog-js"; - +import { createScriptLogger } from "./shared/logger"; import { checkEnvironmentVariables } from "./check-env"; import { applyWranglerEnvOverrides } from "./wrangler-env-overrides"; const startedAt = Date.now(); -const logsDir = path.join(process.cwd(), "logs"); -if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); -} - -const rlog = new Rlog({ - logFilePath: path.join(logsDir, "prebuild.log"), - enableColorfulOutput: true, +const rlog = createScriptLogger({ + logFile: "prebuild.log", }); function log( diff --git a/scripts/seed-local-mock.sql b/scripts/seed-local-mock.sql deleted file mode 100644 index 136cf672..00000000 --- a/scripts/seed-local-mock.sql +++ /dev/null @@ -1,551 +0,0 @@ -PRAGMA foreign_keys = ON; - -DELETE FROM pageviews_archive_hourly -WHERE site_id IN ( - 'mock-site-01', - 'mock-site-02', - 'mock-site-03', - 'mock-site-04', - 'mock-site-05', - 'mock-site-06', - 'mock-site-07', - 'mock-site-08', - 'mock-site-09', - 'mock-site-10' -); - -DELETE FROM pageviews -WHERE site_id IN ( - 'mock-site-01', - 'mock-site-02', - 'mock-site-03', - 'mock-site-04', - 'mock-site-05', - 'mock-site-06', - 'mock-site-07', - 'mock-site-08', - 'mock-site-09', - 'mock-site-10' -); - -DELETE FROM sites -WHERE id IN ( - 'mock-site-01', - 'mock-site-02', - 'mock-site-03', - 'mock-site-04', - 'mock-site-05', - 'mock-site-06', - 'mock-site-07', - 'mock-site-08', - 'mock-site-09', - 'mock-site-10' -); - -WITH -mock_sites (site_ord, site_id, name, domain, public_enabled, public_slug, base_sessions) AS ( - VALUES - (1, 'mock-site-01', 'Northstar Commerce', 'northstar-shop.com', 1, 'northstar-commerce', 42), - (2, 'mock-site-02', 'Beacon CRM', 'beaconcrm.io', 1, 'beacon-crm', 36), - (3, 'mock-site-03', 'Atlas Travel', 'atlas-travel.co', 1, 'atlas-travel', 32), - (4, 'mock-site-04', 'PulseFit', 'pulsefit.app', 0, 'pulsefit', 38), - (5, 'mock-site-05', 'LensLab Media', 'lenslab.media', 1, 'lenslab-media', 28), - (6, 'mock-site-06', 'Summit Academy', 'summitacademy.org', 0, 'summit-academy', 34), - (7, 'mock-site-07', 'UrbanBite Delivery', 'urbanbite.food', 1, 'urbanbite-delivery', 44), - (8, 'mock-site-08', 'Riverbank Finance', 'riverbankpay.com', 0, 'riverbank-finance', 31), - (9, 'mock-site-09', 'CloudNest DevTools', 'cloudnest.dev', 1, 'cloudnest-devtools', 29), - (10, 'mock-site-10', 'GreenLeaf Home', 'greenleafhome.com', 1, 'greenleaf-home', 27) -), -selected_team AS ( - SELECT id AS team_id - FROM teams - ORDER BY CASE WHEN slug = 'admin-team' THEN 0 ELSE 1 END, created_at ASC - LIMIT 1 -) -INSERT INTO sites ( - id, - team_id, - name, - domain, - public_enabled, - public_slug, - created_at, - updated_at -) -SELECT - ms.site_id, - st.team_id, - ms.name, - ms.domain, - ms.public_enabled, - CASE WHEN ms.public_enabled = 1 THEN ms.public_slug ELSE NULL END, - unixepoch() - (45 + ms.site_ord) * 86400, - unixepoch() - (5 + ms.site_ord) * 86400 -FROM mock_sites ms -CROSS JOIN selected_team st; - -WITH RECURSIVE -mock_sites (site_ord, site_id, name, domain, public_enabled, public_slug, base_sessions) AS ( - VALUES - (1, 'mock-site-01', 'Northstar Commerce', 'northstar-shop.com', 1, 'northstar-commerce', 42), - (2, 'mock-site-02', 'Beacon CRM', 'beaconcrm.io', 1, 'beacon-crm', 36), - (3, 'mock-site-03', 'Atlas Travel', 'atlas-travel.co', 1, 'atlas-travel', 32), - (4, 'mock-site-04', 'PulseFit', 'pulsefit.app', 0, 'pulsefit', 38), - (5, 'mock-site-05', 'LensLab Media', 'lenslab.media', 1, 'lenslab-media', 28), - (6, 'mock-site-06', 'Summit Academy', 'summitacademy.org', 0, 'summit-academy', 34), - (7, 'mock-site-07', 'UrbanBite Delivery', 'urbanbite.food', 1, 'urbanbite-delivery', 44), - (8, 'mock-site-08', 'Riverbank Finance', 'riverbankpay.com', 0, 'riverbank-finance', 31), - (9, 'mock-site-09', 'CloudNest DevTools', 'cloudnest.dev', 1, 'cloudnest-devtools', 29), - (10, 'mock-site-10', 'GreenLeaf Home', 'greenleafhome.com', 1, 'greenleaf-home', 27) -), -selected_team AS ( - SELECT id AS team_id - FROM teams - ORDER BY CASE WHEN slug = 'admin-team' THEN 0 ELSE 1 END, created_at ASC - LIMIT 1 -), -day_idx(day_offset) AS ( - SELECT 0 - UNION ALL - SELECT day_offset + 1 - FROM day_idx - WHERE day_offset < 29 -), -session_idx(session_num) AS ( - SELECT 0 - UNION ALL - SELECT session_num + 1 - FROM session_idx - WHERE session_num < 89 -), -event_idx(event_num) AS ( - SELECT 0 - UNION ALL - SELECT event_num + 1 - FROM event_idx - WHERE event_num < 2 -), -geo_profiles ( - geo_id, - country, - region, - region_code, - city, - continent, - latitude, - longitude, - postal_code, - metro_code, - timezone, - is_eu, - as_organization -) AS ( - VALUES - (0, 'US', 'California', 'CA', 'San Francisco', 'NA', 37.7749, -122.4194, '94107', '807', 'America/Los_Angeles', 0, 'Comcast Cable'), - (1, 'US', 'New York', 'NY', 'New York', 'NA', 40.7128, -74.0060, '10001', '501', 'America/New_York', 0, 'Verizon Business'), - (2, 'DE', 'Berlin', 'BE', 'Berlin', 'EU', 52.5200, 13.4050, '10115', '0', 'Europe/Berlin', 1, 'Deutsche Telekom'), - (3, 'FR', 'Ile-de-France', 'IDF', 'Paris', 'EU', 48.8566, 2.3522, '75001', '0', 'Europe/Paris', 1, 'Orange S.A.'), - (4, 'GB', 'England', 'ENG', 'London', 'EU', 51.5074, -0.1278, 'EC1A', '0', 'Europe/London', 0, 'BT Group'), - (5, 'JP', 'Tokyo', '13', 'Tokyo', 'AS', 35.6762, 139.6503, '100-0001', '0', 'Asia/Tokyo', 0, 'NTT Communications'), - (6, 'SG', 'Singapore', '01', 'Singapore', 'AS', 1.3521, 103.8198, '048616', '0', 'Asia/Singapore', 0, 'Singtel'), - (7, 'IN', 'Karnataka', 'KA', 'Bengaluru', 'AS', 12.9716, 77.5946, '560001', '0', 'Asia/Kolkata', 0, 'Bharti Airtel'), - (8, 'BR', 'Sao Paulo', 'SP', 'Sao Paulo', 'SA', -23.5505, -46.6333, '01000-000', '0', 'America/Sao_Paulo', 0, 'Telefonica Brasil'), - (9, 'AU', 'New South Wales', 'NSW', 'Sydney', 'OC', -33.8688, 151.2093, '2000', '0', 'Australia/Sydney', 0, 'Telstra'), - (10, 'CA', 'Ontario', 'ON', 'Toronto', 'NA', 43.6532, -79.3832, 'M5H', '0', 'America/Toronto', 0, 'Rogers Communications'), - (11, 'NL', 'North Holland', 'NH', 'Amsterdam', 'EU', 52.3676, 4.9041, '1012', '0', 'Europe/Amsterdam', 1, 'KPN') -), -ua_profiles ( - ua_id, - browser, - browser_version, - os, - os_version, - device_type, - screen_width, - screen_height, - language, - ua_raw -) AS ( - VALUES - (0, 'Chrome', '122.0', 'Windows', '11', 'desktop', 1920, 1080, 'en-US', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'), - (1, 'Safari', '17.3', 'macOS', '14.3', 'desktop', 1512, 982, 'en-US', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15'), - (2, 'Safari', '17.2', 'iOS', '17.2', 'mobile', 390, 844, 'en-US', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1'), - (3, 'Chrome', '121.0', 'Android', '14', 'mobile', 412, 915, 'en-US', 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.178 Mobile Safari/537.36'), - (4, 'Edge', '122.0', 'Windows', '11', 'desktop', 1366, 768, 'en-US', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'), - (5, 'Firefox', '123.0', 'Linux', '6.8', 'desktop', 1440, 900, 'en-US', 'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'), - (6, 'Chrome', '122.0', 'macOS', '14.3', 'desktop', 1728, 1117, 'en-GB', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'), - (7, 'Samsung Internet', '25.0', 'Android', '14', 'mobile', 360, 800, 'en-US', 'Mozilla/5.0 (Linux; Android 14; SAMSUNG SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36') -), -sessions AS ( - SELECT - ms.site_id, - ms.site_ord, - ms.name AS site_name, - ms.domain, - d.day_offset, - s.session_num, - (ms.base_sessions + ((d.day_offset * 7 + ms.site_ord * 11) % 22) - 8) AS sessions_for_day - FROM mock_sites ms - CROSS JOIN day_idx d - CROSS JOIN session_idx s -), -selected_sessions AS ( - SELECT - site_id, - site_ord, - site_name, - domain, - day_offset, - session_num, - ((session_num * 7 + day_offset * 3 + site_ord) % 12) AS geo_id, - ((session_num * 5 + day_offset * 2 + site_ord) % 8) AS ua_id - FROM sessions - WHERE session_num < sessions_for_day -), -events AS ( - SELECT - ss.site_id, - ss.site_ord, - ss.site_name, - ss.domain, - ss.day_offset, - ss.session_num, - ss.geo_id, - ss.ua_id, - e.event_num, - CASE - WHEN ((ss.session_num + ss.day_offset + ss.site_ord) % 5) = 0 THEN 3 - ELSE 2 - END AS events_in_session - FROM selected_sessions ss - CROSS JOIN event_idx e -), -selected_events AS ( - SELECT - site_id, - site_ord, - site_name, - domain, - day_offset, - session_num, - geo_id, - ua_id, - event_num - FROM events - WHERE event_num < events_in_session -), -resolved AS ( - SELECT - se.site_id, - se.site_ord, - se.site_name, - se.domain, - se.day_offset, - se.session_num, - se.event_num, - gp.country, - gp.region, - gp.region_code, - gp.city, - gp.continent, - gp.latitude, - gp.longitude, - gp.postal_code, - gp.metro_code, - gp.timezone, - gp.is_eu, - gp.as_organization, - up.browser, - up.browser_version, - up.os, - up.os_version, - up.device_type, - up.screen_width, - up.screen_height, - up.language, - up.ua_raw - FROM selected_events se - INNER JOIN geo_profiles gp ON gp.geo_id = se.geo_id - INNER JOIN ua_profiles up ON up.ua_id = se.ua_id -), -final_rows AS ( - SELECT - st.team_id, - r.site_id, - r.site_ord, - r.site_name, - r.domain, - r.day_offset, - r.session_num, - r.event_num, - r.country, - r.region, - r.region_code, - r.city, - r.continent, - r.latitude, - r.longitude, - r.postal_code, - r.metro_code, - r.timezone, - r.is_eu, - r.as_organization, - r.browser, - r.browser_version, - r.os, - r.os_version, - r.device_type, - r.screen_width, - r.screen_height, - r.language, - r.ua_raw, - ( - CAST(strftime('%s', 'now', 'start of day', '-29 days', printf('+%d days', r.day_offset)) AS INTEGER) * 1000 - + ((((r.session_num * 37 + r.site_ord * 17 + r.day_offset * 11) % 840) + 360) * 60000) - + (r.event_num * ((45 + ((r.session_num + r.site_ord) % 4) * 30) * 1000)) - + (((r.session_num * 13 + r.day_offset * 17) % 50) * 1000) - ) AS event_at_ms, - ((r.session_num * 3 + r.day_offset + r.site_ord) % 7) AS ref_idx, - ((r.session_num + r.day_offset + r.site_ord) % 6) AS campaign_idx, - ((r.session_num + r.event_num + r.day_offset) % 7) AS path_idx - FROM resolved r - CROSS JOIN selected_team st -) -INSERT INTO pageviews ( - id, - team_id, - site_id, - event_type, - event_at, - received_at, - hour_bucket, - pathname, - query_string, - hash_fragment, - title, - hostname, - referer, - referer_host, - utm_source, - utm_medium, - utm_campaign, - utm_term, - utm_content, - visitor_id, - session_id, - duration_ms, - is_eu, - country, - region, - region_code, - city, - continent, - latitude, - longitude, - postal_code, - metro_code, - timezone, - as_organization, - ua_raw, - browser, - browser_version, - os, - os_version, - device_type, - screen_width, - screen_height, - language, - created_at -) -SELECT - printf('mock-%s-d%02d-s%03d-e%d', fr.site_id, fr.day_offset, fr.session_num, fr.event_num) AS id, - fr.team_id, - fr.site_id, - CASE - WHEN fr.event_num = 0 THEN 'pageview' - WHEN ((fr.session_num + fr.day_offset + fr.site_ord) % 17) = 0 THEN 'purchase' - WHEN ((fr.session_num + fr.site_ord) % 9) = 0 THEN 'signup_submit' - WHEN ((fr.session_num + fr.day_offset) % 4) = 0 THEN 'click_cta' - ELSE 'scroll_75' - END AS event_type, - fr.event_at_ms AS event_at, - fr.event_at_ms + 120 + ((fr.session_num * 19 + fr.event_num * 13) % 700) AS received_at, - CAST(fr.event_at_ms / 3600000 AS INTEGER) AS hour_bucket, - CASE fr.site_ord - WHEN 1 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/collections/new-arrivals' - WHEN 2 THEN '/products/smart-lamp' - WHEN 3 THEN '/products/air-purifier' - WHEN 4 THEN '/cart' - WHEN 5 THEN '/checkout' - ELSE '/blog/spring-style-guide' - END - WHEN 2 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/pricing' - WHEN 2 THEN '/features/automation' - WHEN 3 THEN '/docs/getting-started' - WHEN 4 THEN '/customers' - WHEN 5 THEN '/signup' - ELSE '/blog/product-updates' - END - WHEN 3 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/destinations/japan' - WHEN 2 THEN '/destinations/iceland' - WHEN 3 THEN '/packages/weekend-city' - WHEN 4 THEN '/deals' - WHEN 5 THEN '/checkout' - ELSE '/blog/travel-tips' - END - WHEN 4 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/plans' - WHEN 2 THEN '/workouts/hiit-20' - WHEN 3 THEN '/nutrition/protein-guide' - WHEN 4 THEN '/app' - WHEN 5 THEN '/subscribe' - ELSE '/community' - END - WHEN 5 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/article/ai-agent-observability' - WHEN 2 THEN '/article/edge-caching-patterns' - WHEN 3 THEN '/newsletter' - WHEN 4 THEN '/podcast/episode-12' - WHEN 5 THEN '/about' - ELSE '/contact' - END - WHEN 6 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/courses/data-analytics' - WHEN 2 THEN '/courses/product-management' - WHEN 3 THEN '/pricing' - WHEN 4 THEN '/webinars' - WHEN 5 THEN '/enroll' - ELSE '/blog/student-stories' - END - WHEN 7 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/menu' - WHEN 2 THEN '/restaurants/sushi-house' - WHEN 3 THEN '/restaurants/burger-park' - WHEN 4 THEN '/cart' - WHEN 5 THEN '/checkout' - ELSE '/offers/weeknight-deals' - END - WHEN 8 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/product/card' - WHEN 2 THEN '/product/savings' - WHEN 3 THEN '/pricing' - WHEN 4 THEN '/security' - WHEN 5 THEN '/signup' - ELSE '/support' - END - WHEN 9 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/docs' - WHEN 2 THEN '/docs/sdk/javascript' - WHEN 3 THEN '/pricing' - WHEN 4 THEN '/changelog' - WHEN 5 THEN '/login' - ELSE '/blog/performance-benchmark' - END - WHEN 10 THEN CASE fr.path_idx - WHEN 0 THEN '/' - WHEN 1 THEN '/collections/living-room' - WHEN 2 THEN '/products/oak-dining-table' - WHEN 3 THEN '/products/linen-sofa' - WHEN 4 THEN '/cart' - WHEN 5 THEN '/checkout' - ELSE '/inspiration' - END - ELSE '/' - END AS pathname, - CASE fr.campaign_idx - WHEN 0 THEN 'utm_source=google&utm_medium=cpc&utm_campaign=spring_launch' - WHEN 1 THEN 'utm_source=linkedin&utm_medium=social&utm_campaign=thought_leadership' - WHEN 2 THEN 'utm_source=newsletter&utm_medium=email&utm_campaign=monthly_digest' - ELSE NULL - END AS query_string, - CASE - WHEN fr.site_ord IN (2, 8, 9) AND fr.event_num = 1 AND (fr.session_num % 5) = 0 THEN 'faq' - ELSE '' - END AS hash_fragment, - printf('%s | session %03d', fr.site_name, fr.session_num) AS title, - fr.domain AS hostname, - CASE fr.ref_idx - WHEN 0 THEN '' - WHEN 1 THEN 'https://www.google.com/search?q=product+analytics' - WHEN 2 THEN 'https://www.bing.com/search?q=traffic+dashboard' - WHEN 3 THEN 'https://www.linkedin.com/feed/' - WHEN 4 THEN 'https://twitter.com/' - WHEN 5 THEN 'https://github.com/trending' - ELSE 'https://www.reddit.com/r/webdev/' - END AS referer, - CASE fr.ref_idx - WHEN 0 THEN '' - WHEN 1 THEN 'google.com' - WHEN 2 THEN 'bing.com' - WHEN 3 THEN 'linkedin.com' - WHEN 4 THEN 'twitter.com' - WHEN 5 THEN 'github.com' - ELSE 'reddit.com' - END AS referer_host, - CASE fr.campaign_idx - WHEN 0 THEN 'google' - WHEN 1 THEN 'linkedin' - WHEN 2 THEN 'newsletter' - ELSE NULL - END AS utm_source, - CASE fr.campaign_idx - WHEN 0 THEN 'cpc' - WHEN 1 THEN 'social' - WHEN 2 THEN 'email' - ELSE NULL - END AS utm_medium, - CASE fr.campaign_idx - WHEN 0 THEN 'spring_launch' - WHEN 1 THEN 'thought_leadership' - WHEN 2 THEN 'monthly_digest' - ELSE NULL - END AS utm_campaign, - CASE - WHEN fr.campaign_idx = 0 THEN 'analytics platform' - ELSE NULL - END AS utm_term, - CASE - WHEN fr.campaign_idx IN (0, 1, 2) THEN lower(replace(fr.site_name, ' ', '_')) - ELSE NULL - END AS utm_content, - printf('%s-v%03d', fr.site_id, ((fr.session_num * 5 + fr.day_offset * 7 + fr.site_ord) % 260)) AS visitor_id, - printf('%s-d%02d-s%03d', fr.site_id, fr.day_offset, fr.session_num) AS session_id, - CASE - WHEN fr.event_num = 0 AND ((fr.session_num + fr.day_offset) % 8) = 0 THEN 0 - ELSE (25 + ((fr.session_num * 13 + fr.day_offset * 7 + fr.site_ord * 3 + fr.event_num * 11) % 240)) * 1000 - END AS duration_ms, - fr.is_eu, - fr.country, - fr.region, - fr.region_code, - fr.city, - fr.continent, - fr.latitude, - fr.longitude, - fr.postal_code, - fr.metro_code, - fr.timezone, - fr.as_organization, - fr.ua_raw, - fr.browser, - fr.browser_version, - fr.os, - fr.os_version, - fr.device_type, - fr.screen_width, - fr.screen_height, - fr.language, - CAST(fr.event_at_ms / 1000 AS INTEGER) AS created_at -FROM final_rows fr; diff --git a/scripts/shared/logger.ts b/scripts/shared/logger.ts new file mode 100644 index 00000000..b44115d0 --- /dev/null +++ b/scripts/shared/logger.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; + +import Rlog from "rlog-js"; + +import { LOGS_DIR } from "./paths"; + +interface ScriptLoggerOptions { + logFile?: string; + silent?: boolean; +} + +export function createScriptLogger(options: ScriptLoggerOptions = {}): Rlog { + const logFilePath = options.logFile + ? path.join(LOGS_DIR, options.logFile) + : undefined; + + if (logFilePath && !fs.existsSync(LOGS_DIR)) { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + } + + return new Rlog({ + enableColorfulOutput: true, + logFilePath, + silent: options.silent, + }); +} diff --git a/scripts/shared/paths.ts b/scripts/shared/paths.ts new file mode 100644 index 00000000..2ec71a0d --- /dev/null +++ b/scripts/shared/paths.ts @@ -0,0 +1,5 @@ +import path from "node:path"; +import process from "node:process"; + +export const ROOT_DIR = process.cwd(); +export const LOGS_DIR = path.join(ROOT_DIR, "logs"); diff --git a/scripts/skills-template.json b/scripts/skills-template.json index 436f6b4e..fdc97a59 100644 --- a/scripts/skills-template.json +++ b/scripts/skills-template.json @@ -24,10 +24,11 @@ } }, "common_query_parameters": { - "description": "These parameters are available on all analytics query endpoints (queryName-based). They are not repeated per-endpoint for brevity.", + "description": "These parameters are available on analytics query endpoints. The OpenAPI document remains the source of truth for endpoint-specific parameters.", "time_window": { - "from": "Start timestamp in Unix milliseconds (required).", - "to": "End timestamp in Unix milliseconds (required).", + "from": "Inclusive ISO 8601 date-time string. Optional when preset is used.", + "to": "Exclusive ISO 8601 date-time string. Optional when preset is used.", + "preset": "Named time range preset such as last_7_days or last_30_days. Mutually exclusive with from/to.", "timeZone": "IANA timezone identifier (e.g. America/New_York). Defaults to UTC.", "tz": "Alias for timeZone." }, @@ -64,17 +65,17 @@ "typical_workflow": [ "1. Obtain an API key from the user (ask them, or direct them to dashboard → Settings → API Keys).", "2. Call GET /api/v1/sites to list available sites and get siteId values.", - "3. Use the siteId to query analytics: GET /api/v1/sites/{siteId}/analytics/overview?from=...&to=...", - "4. For time-series data, use the /trend endpoint with an interval parameter.", - "5. For multiple queries at once, use POST /api/v1/sites/{siteId}/analytics/batch.", - "6. For team-level summary, use GET /api/v1/team/dashboard." + "3. Use the siteId to query analytics: GET /api/v1/sites/{siteId}/analytics/overview?preset=last_7_days&timeZone=UTC.", + "4. For time-series data, use GET /api/v1/sites/{siteId}/analytics/timeseries with an interval parameter.", + "5. For multiple queries at once, use POST /api/v1/batch.", + "6. For team-level summary, use GET /api/v1/team/analytics/overview." ], "implementation_notes": [ - "All timestamps are in Unix milliseconds (not seconds).", + "All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with Ms.", "The siteId parameter is a UUID. Obtain it from GET /api/v1/sites.", - "Pagination uses page/pageSize for visitors and sessions endpoints.", - "The /analytics/{queryName} path supports all query names listed in the endpoints above.", - "Rate limits are not currently enforced but may be added. Design for graceful 429 handling.", + "Pagination uses cursor/limit for visitors, sessions, and events endpoints.", + "Use the OpenAPI paths directly; analytics endpoints are explicit resources such as /analytics/overview, /analytics/timeseries, and /analytics/breakdowns/{dimension}.", + "Depending on deployment configuration, upstream infrastructure may return additional responses such as 429 before requests reach the API origin. Those responses are outside the stable API error envelope.", "For LLM agents: always ask the user for an API key before making any request. Do not attempt to guess or generate keys." ] } diff --git a/src/app/.well-known/openapi.json/route.ts b/src/app/.well-known/openapi.json/route.ts index b6ec1a99..2dc39d9a 100644 --- a/src/app/.well-known/openapi.json/route.ts +++ b/src/app/.well-known/openapi.json/route.ts @@ -7,8 +7,10 @@ const HEADERS = { }; function getBaseUrl(request: Request): string { - const host = request.headers.get("host"); + const host = + request.headers.get("x-forwarded-host") ?? request.headers.get("host"); const proto = request.headers.get("x-forwarded-proto") ?? "https"; + if (!host) return new URL(request.url).origin; return `${proto}://${host}`; } diff --git a/src/app/.well-known/skills.json/route.ts b/src/app/.well-known/skills.json/route.ts index bc6a9ca6..239785fa 100644 --- a/src/app/.well-known/skills.json/route.ts +++ b/src/app/.well-known/skills.json/route.ts @@ -7,8 +7,10 @@ const HEADERS = { }; function getBaseUrl(request: Request): string { - const host = request.headers.get("host"); + const host = + request.headers.get("x-forwarded-host") ?? request.headers.get("host"); const proto = request.headers.get("x-forwarded-proto") ?? "https"; + if (!host) return new URL(request.url).origin; return `${proto}://${host}`; } diff --git a/src/app/[locale]/app/[teamSlug]/[siteSlug]/settings/page.tsx b/src/app/[locale]/app/[teamSlug]/[siteSlug]/settings/page.tsx index ace457e0..9ada4252 100644 --- a/src/app/[locale]/app/[teamSlug]/[siteSlug]/settings/page.tsx +++ b/src/app/[locale]/app/[teamSlug]/[siteSlug]/settings/page.tsx @@ -49,6 +49,7 @@ export default async function SiteSettingsPage({ id: context.activeSite.id, name: context.activeSite.name, domain: context.activeSite.domain, + publicEnabled: context.activeSite.publicEnabled, publicSlug: context.activeSite.publicSlug, }} /> diff --git a/src/app/[locale]/app/[teamSlug]/public-links/page.tsx b/src/app/[locale]/app/[teamSlug]/public-links/page.tsx index 701ed79b..5d29738f 100644 --- a/src/app/[locale]/app/[teamSlug]/public-links/page.tsx +++ b/src/app/[locale]/app/[teamSlug]/public-links/page.tsx @@ -1,8 +1,21 @@ +import { headers } from "next/headers"; +import Link from "next/link"; import { notFound } from "next/navigation"; -import { RiLinksLine } from "@remixicon/react"; +import { RiExternalLinkLine, RiLinksLine } from "@remixicon/react"; import { PageHeading } from "@/components/dashboard/page-heading"; -import { Card, CardContent } from "@/components/ui/card"; +import { PublicLinkCopyButton } from "@/components/dashboard/public-link-copy-button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { canManageTeam } from "@/lib/dashboard/permissions"; import { getDashboardTeamContext } from "@/lib/dashboard/server"; import { resolveLocale } from "@/lib/i18n/config"; @@ -15,6 +28,18 @@ interface TeamPublicLinksPageProps { }>; } +async function requestOrigin(): Promise { + const h = await headers(); + const host = h.get("x-forwarded-host") || h.get("host") || ""; + if (!host) return ""; + const proto = + h.get("x-forwarded-proto") || + (host.startsWith("localhost") || host.startsWith("127.0.0.1") + ? "http" + : "https"); + return `${proto}://${host}`; +} + export async function generateMetadata({ params }: TeamPublicLinksPageProps) { const { locale } = await params; const resolvedLocale = resolveLocale(locale); @@ -41,14 +66,104 @@ export default async function TeamPublicLinksPage({ } const copy = messages.teamManagement.publicLinks; + const origin = await requestOrigin(); return (
- - -

{copy.empty}

+ + + + {copy.title} + + + + {context.sites.length === 0 ? ( +
+ +

{copy.noSites}

+
+ ) : ( + + + + {copy.columns.site} + {copy.columns.domain} + {copy.columns.publicUrl} + {copy.columns.status} + + {copy.columns.action} + + + + + {context.sites.map((site) => { + const enabled = Boolean( + site.publicEnabled && site.publicSlug, + ); + const publicUrl = enabled + ? `${origin}/${resolvedLocale}/share/${encodeURIComponent( + site.publicSlug || "", + )}` + : ""; + const settingsHref = `/${resolvedLocale}/app/${context.activeTeam.slug}/${site.slug}/settings`; + + return ( + + +
{site.name}
+
+ {site.slug} +
+
+ + {site.domain} + + + {enabled ? ( +
+ + {publicUrl} + + +
+ ) : ( + + {copy.disabledHint} + + )} +
+ + + {enabled ? copy.enabled : copy.disabled} + + + +
+ {enabled ? ( + + ) : null} + +
+
+
+ ); + })} +
+
+ )}
diff --git a/src/app/[locale]/share/[slug]/browsers/page.tsx b/src/app/[locale]/share/[slug]/browsers/page.tsx new file mode 100644 index 00000000..5825995b --- /dev/null +++ b/src/app/[locale]/share/[slug]/browsers/page.tsx @@ -0,0 +1,28 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { BrowsersClientPage } from "@/components/dashboard/site-pages/browsers-client-page"; + +interface ShareBrowsersPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareBrowsersPage({ + params, +}: ShareBrowsersPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/campaigns/page.tsx b/src/app/[locale]/share/[slug]/campaigns/page.tsx new file mode 100644 index 00000000..07c9af66 --- /dev/null +++ b/src/app/[locale]/share/[slug]/campaigns/page.tsx @@ -0,0 +1,28 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { CampaignsClientPage } from "@/components/dashboard/site-pages/campaigns-client-page"; + +interface ShareCampaignsPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareCampaignsPage({ + params, +}: ShareCampaignsPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/devices/page.tsx b/src/app/[locale]/share/[slug]/devices/page.tsx new file mode 100644 index 00000000..75d10359 --- /dev/null +++ b/src/app/[locale]/share/[slug]/devices/page.tsx @@ -0,0 +1,29 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { DevicesClientPage } from "@/components/dashboard/site-pages/devices-client-page"; + +interface ShareDevicesPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareDevicesPage({ + params, +}: ShareDevicesPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/geo/page.tsx b/src/app/[locale]/share/[slug]/geo/page.tsx new file mode 100644 index 00000000..b542331a --- /dev/null +++ b/src/app/[locale]/share/[slug]/geo/page.tsx @@ -0,0 +1,22 @@ +import { getShareRouteContext } from "@/app/[locale]/share/[slug]/share-utils"; +import { GeoClientPage } from "@/components/dashboard/site-pages/geo-client-page"; + +interface ShareGeoPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareGeoPage({ params }: ShareGeoPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/layout.tsx b/src/app/[locale]/share/[slug]/layout.tsx new file mode 100644 index 00000000..19643ebc --- /dev/null +++ b/src/app/[locale]/share/[slug]/layout.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +import { ShareDashboardShell } from "@/components/dashboard/share-dashboard-shell"; + +import { getShareRouteContext } from "./share-utils"; + +interface ShareLayoutProps { + children: ReactNode; + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export default async function ShareLayout({ + children, + params, +}: ShareLayoutProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + {children} + + ); +} diff --git a/src/app/[locale]/share/[slug]/page.tsx b/src/app/[locale]/share/[slug]/page.tsx new file mode 100644 index 00000000..a35f27df --- /dev/null +++ b/src/app/[locale]/share/[slug]/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; + +import { OverviewClientPage } from "@/components/dashboard/site-pages/overview-client-page"; +import { formatI18nTemplate } from "@/lib/i18n/template"; + +import { getShareRouteContext, sharePath } from "./share-utils"; + +interface SharePageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export async function generateMetadata({ + params, +}: SharePageProps): Promise { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + return { + title: formatI18nTemplate(context.messages.share.title, { + siteName: context.site.name, + }), + }; +} + +export default async function ShareOverviewPage({ params }: SharePageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/pages/[pageKey]/page.tsx b/src/app/[locale]/share/[slug]/pages/[pageKey]/page.tsx new file mode 100644 index 00000000..a7b2f404 --- /dev/null +++ b/src/app/[locale]/share/[slug]/pages/[pageKey]/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from "next/navigation"; + +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { PageDetailClientPage } from "@/components/dashboard/site-pages/page-detail-client-page"; +import { + normalizePagePath, + PAGE_DETAIL_QUERY_PARAM, +} from "@/lib/dashboard/page-detail"; + +interface SharePageDetailPageProps { + params: Promise<{ + locale: string; + slug: string; + pageKey: string; + }>; + searchParams: Promise<{ + pagePath?: string; + }>; +} + +export default async function SharePageDetailPage({ + params, + searchParams, +}: SharePageDetailPageProps) { + const { locale, slug } = await params; + const search = await searchParams; + const pagePath = normalizePagePath(search[PAGE_DETAIL_QUERY_PARAM]); + + if (!pagePath) notFound(); + + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/pages/page.tsx b/src/app/[locale]/share/[slug]/pages/page.tsx new file mode 100644 index 00000000..38df48d9 --- /dev/null +++ b/src/app/[locale]/share/[slug]/pages/page.tsx @@ -0,0 +1,26 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { PagesClientPage } from "@/components/dashboard/site-pages/pages-client-page"; + +interface SharePagesPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function SharePagesPage({ params }: SharePagesPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/performance/page.tsx b/src/app/[locale]/share/[slug]/performance/page.tsx new file mode 100644 index 00000000..311064af --- /dev/null +++ b/src/app/[locale]/share/[slug]/performance/page.tsx @@ -0,0 +1,24 @@ +import { getShareRouteContext } from "@/app/[locale]/share/[slug]/share-utils"; +import { PerformanceClientPage } from "@/components/dashboard/site-pages/performance-client-page"; + +interface SharePerformancePageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function SharePerformancePage({ + params, +}: SharePerformancePageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/referrers/page.tsx b/src/app/[locale]/share/[slug]/referrers/page.tsx new file mode 100644 index 00000000..746542df --- /dev/null +++ b/src/app/[locale]/share/[slug]/referrers/page.tsx @@ -0,0 +1,28 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { ReferrersClientPage } from "@/components/dashboard/site-pages/referrers-client-page"; + +interface ShareReferrersPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareReferrersPage({ + params, +}: ShareReferrersPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/retention/page.tsx b/src/app/[locale]/share/[slug]/retention/page.tsx new file mode 100644 index 00000000..cf293cb8 --- /dev/null +++ b/src/app/[locale]/share/[slug]/retention/page.tsx @@ -0,0 +1,28 @@ +import { + getShareRouteContext, + sharePath, +} from "@/app/[locale]/share/[slug]/share-utils"; +import { RetentionClientPage } from "@/components/dashboard/site-pages/retention-client-page"; + +interface ShareRetentionPageProps { + params: Promise<{ + locale: string; + slug: string; + }>; +} + +export default async function ShareRetentionPage({ + params, +}: ShareRetentionPageProps) { + const { locale, slug } = await params; + const context = await getShareRouteContext(locale, slug); + + return ( + + ); +} diff --git a/src/app/[locale]/share/[slug]/share-utils.ts b/src/app/[locale]/share/[slug]/share-utils.ts new file mode 100644 index 00000000..5fdebe85 --- /dev/null +++ b/src/app/[locale]/share/[slug]/share-utils.ts @@ -0,0 +1,36 @@ +import { notFound } from "next/navigation"; + +import { publicDashboardSiteId } from "@/lib/dashboard/client-request"; +import { fetchPublicSite, type PublicSiteData } from "@/lib/edge-client"; +import { type Locale, resolveLocale } from "@/lib/i18n/config"; +import { type AppMessages, getMessages } from "@/lib/i18n/messages"; + +export interface ShareRouteContext { + locale: Locale; + messages: AppMessages; + site: PublicSiteData; + publicSiteId: string; +} + +export function sharePath(locale: Locale, slug: string, section?: string) { + const base = `/${locale}/share/${encodeURIComponent(slug)}`; + return section ? `${base}/${section}` : base; +} + +export async function getShareRouteContext( + locale: string, + slug: string, +): Promise { + const resolvedLocale = resolveLocale(locale); + try { + const site = await fetchPublicSite(slug); + return { + locale: resolvedLocale, + messages: getMessages(resolvedLocale), + site, + publicSiteId: publicDashboardSiteId(slug), + }; + } catch { + notFound(); + } +} diff --git a/src/app/__tests__/well-known-discovery-routes.test.ts b/src/app/__tests__/well-known-discovery-routes.test.ts new file mode 100644 index 00000000..4164c21f --- /dev/null +++ b/src/app/__tests__/well-known-discovery-routes.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { GET as getOpenApi } from "@/app/.well-known/openapi.json/route"; +import { GET as getSkills } from "@/app/.well-known/skills.json/route"; + +describe(".well-known discovery routes", () => { + it("resolves the Skills base URL from forwarded request headers", async () => { + const response = getSkills( + new Request("http://internal.test/.well-known/skills.json", { + headers: { + host: "internal.test", + "x-forwarded-host": "analytics.example.test", + "x-forwarded-proto": "https", + }, + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + baseUrl: "https://analytics.example.test", + }); + }); + + it("resolves OpenAPI servers from forwarded request headers", async () => { + const response = getOpenApi( + new Request("http://internal.test/.well-known/openapi.json", { + headers: { + host: "internal.test", + "x-forwarded-host": "analytics.example.test", + "x-forwarded-proto": "https", + }, + }), + ); + const body = (await response.json()) as { + servers?: Array<{ url?: string }>; + }; + + expect(body.servers).toEqual([ + expect.objectContaining({ url: "https://analytics.example.test" }), + ]); + }); +}); diff --git a/src/app/api/__tests__/edge-query-routes.test.ts b/src/app/api/__tests__/edge-query-routes.test.ts index e747bcbc..db02352b 100644 --- a/src/app/api/__tests__/edge-query-routes.test.ts +++ b/src/app/api/__tests__/edge-query-routes.test.ts @@ -6,33 +6,26 @@ import { PATCH as privatePATCH, POST as privatePOST, } from "@/app/api/private/[...segments]/route"; -import { GET as publicGET } from "@/app/api/public/[...segments]/route"; -import { handlePrivateAdmin } from "@/lib/edge/admin"; -import { handlePrivateArchive } from "@/lib/edge/archive-query"; -import { handlePrivateQuery, handlePublicQuery } from "@/lib/edge/query"; +import { + DELETE as publicDELETE, + GET as publicGET, + PATCH as publicPATCH, + POST as publicPOST, +} from "@/app/api/public/[...segments]/route"; import { resolveEdgeRuntime } from "@/lib/edge/runtime"; - -vi.mock("@/lib/edge/admin", () => ({ - handlePrivateAdmin: vi.fn(), -})); - -vi.mock("@/lib/edge/archive-query", () => ({ - handlePrivateArchive: vi.fn(), -})); - -vi.mock("@/lib/edge/query", () => ({ - handlePrivateQuery: vi.fn(), - handlePublicQuery: vi.fn(), -})); +import apiApp from "@/lib/hono/app"; vi.mock("@/lib/edge/runtime", () => ({ resolveEdgeRuntime: vi.fn(), })); -const handlePrivateAdminMock = vi.mocked(handlePrivateAdmin); -const handlePrivateArchiveMock = vi.mocked(handlePrivateArchive); -const handlePrivateQueryMock = vi.mocked(handlePrivateQuery); -const handlePublicQueryMock = vi.mocked(handlePublicQuery); +vi.mock("@/lib/hono/app", () => ({ + default: { + fetch: vi.fn(), + }, +})); + +const apiFetchMock = vi.mocked(apiApp.fetch); const resolveEdgeRuntimeMock = vi.mocked(resolveEdgeRuntime); const env = { DB: {} }; @@ -55,110 +48,30 @@ function mockRuntime(pathname: string, method = "GET") { describe("edge query route wrappers", () => { beforeEach(() => { - handlePrivateAdminMock.mockReset(); - handlePrivateArchiveMock.mockReset(); - handlePrivateQueryMock.mockReset(); - handlePublicQueryMock.mockReset(); + apiFetchMock.mockReset(); resolveEdgeRuntimeMock.mockReset(); - handlePrivateAdminMock.mockResolvedValue(new Response("admin")); - handlePrivateArchiveMock.mockResolvedValue(new Response("archive")); - handlePrivateQueryMock.mockResolvedValue(new Response("private-query")); - handlePublicQueryMock.mockResolvedValue(new Response("public-query")); - }); - - it("routes private admin requests to the admin handler", async () => { - const original = mockRuntime("/api/private/admin/users"); - - const response = await privateGET(original); - - expect(await response.text()).toBe("admin"); - expect(handlePrivateAdminMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/admin/users"), - ); - expect(handlePrivateArchiveMock).not.toHaveBeenCalled(); - expect(handlePrivateQueryMock).not.toHaveBeenCalled(); - }); - - it("routes private archive requests to the archive handler", async () => { - const original = mockRuntime("/api/private/archive/manifest"); - - const response = await privatePOST(original); - - expect(await response.text()).toBe("archive"); - expect(handlePrivateArchiveMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/archive/manifest"), - ); - }); - - it("routes other private requests to the query handler with execution context", async () => { - const original = mockRuntime("/api/private/overview", "PATCH"); - - const response = await privatePATCH(original); - - expect(await response.text()).toBe("private-query"); - expect(handlePrivateQueryMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/overview"), - ctx, - ); - }); - - it("routes public requests to the public query handler", async () => { - const original = mockRuntime("/api/public/site/overview"); - - const response = await publicGET(original); - - expect(await response.text()).toBe("public-query"); - expect(handlePublicQueryMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/public/site/overview"), - ctx, - ); - }); - - it("routes DELETE requests to the query handler", async () => { - const original = mockRuntime("/api/private/funnels?id=abc", "DELETE"); - - const response = await privateDELETE(original); - - expect(await response.text()).toBe("private-query"); - expect(handlePrivateQueryMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/funnels?id=abc"), - ctx, - ); + apiFetchMock.mockResolvedValue(new Response("hono")); }); - it("routes DELETE admin requests to the admin handler", async () => { - const original = mockRuntime("/api/private/admin/users/123", "DELETE"); - - const response = await privateDELETE(original); - - expect(await response.text()).toBe("admin"); - expect(handlePrivateAdminMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/admin/users/123"), - ); - }); - - it("routes DELETE archive requests to the archive handler", async () => { - const original = mockRuntime("/api/private/archive/data", "DELETE"); - - const response = await privateDELETE(original); - - expect(await response.text()).toBe("archive"); - expect(handlePrivateArchiveMock).toHaveBeenCalledWith( - expect.any(Request), - env, - new URL("https://app.test/api/private/archive/data"), - ); - }); + it.each([ + ["private GET", privateGET, "/api/private/admin/users", "GET"], + ["private POST", privatePOST, "/api/private/archive/manifest", "POST"], + ["private PATCH", privatePATCH, "/api/private/overview", "PATCH"], + ["private DELETE", privateDELETE, "/api/private/funnels", "DELETE"], + ["public GET", publicGET, "/api/public/site/overview", "GET"], + ["public POST", publicPOST, "/api/public/site/overview", "POST"], + ["public PATCH", publicPATCH, "/api/public/site/overview", "PATCH"], + ["public DELETE", publicDELETE, "/api/public/site/overview", "DELETE"], + ])( + "delegates %s to the shared Hono app", + async (_label, handler, path, method) => { + const original = mockRuntime(path, method); + + const response = await handler(original); + + expect(await response.text()).toBe("hono"); + expect(apiFetchMock).toHaveBeenCalledWith(expect.any(Request), env, ctx); + expect(resolveEdgeRuntimeMock).toHaveBeenCalledWith(original); + }, + ); }); diff --git a/src/app/api/map-tiles/[z]/[x]/[y]/route.ts b/src/app/api/map-tiles/[z]/[x]/[y]/route.ts index d499035a..72b7ae67 100644 --- a/src/app/api/map-tiles/[z]/[x]/[y]/route.ts +++ b/src/app/api/map-tiles/[z]/[x]/[y]/route.ts @@ -1,63 +1,4 @@ -import { requireSameOrigin } from "@/lib/edge/utils"; - -const LIGHT_TILE_UPSTREAMS = [ - "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", - "https://basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png", - "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", -] as const; - -const DARK_TILE_UPSTREAMS = [ - "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", - "https://basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png", -] as const; - -type TileTheme = "light" | "dark"; - -function parseIntStrict(value: string): number | null { - if (!/^\d+$/.test(value)) return null; - const next = Number.parseInt(value, 10); - return Number.isFinite(next) ? next : null; -} - -function resolveY(raw: string): number | null { - const normalized = raw.endsWith(".png") ? raw.slice(0, -4) : raw; - return parseIntStrict(normalized); -} - -function validateTileCoordinate(z: number, x: number, y: number): boolean { - if (z < 0 || z > 20) return false; - const max = 2 ** z; - return y >= 0 && y < max && Number.isFinite(x); -} - -function normalizeTileX(x: number, z: number): number { - const max = 2 ** z; - return ((x % max) + max) % max; -} - -function buildUpstreamUrl( - template: string, - z: number, - x: number, - y: number, -): string { - return template - .replace("{z}", String(z)) - .replace("{x}", String(x)) - .replace("{y}", String(y)); -} - -function resolveTileTheme(request: Request): TileTheme { - const url = new URL(request.url); - return url.searchParams.get("theme") === "dark" ? "dark" : "light"; -} - -function resolveTileUpstreams(theme: TileTheme): readonly string[] { - if (theme === "dark") { - return [...DARK_TILE_UPSTREAMS, ...LIGHT_TILE_UPSTREAMS]; - } - return LIGHT_TILE_UPSTREAMS; -} +import { handleMapTileRequest } from "@/lib/edge/map-tiles"; export async function GET( request: Request, @@ -65,65 +6,5 @@ export async function GET( params: Promise<{ z: string; x: string; y: string }>; }, ): Promise { - const sameOriginError = requireSameOrigin(request); - if (sameOriginError) return sameOriginError; - - const { z: rawZ, x: rawX, y: rawY } = await context.params; - const z = parseIntStrict(rawZ); - const x = parseIntStrict(rawX); - const y = resolveY(rawY); - - if ( - z === null || - x === null || - y === null || - !validateTileCoordinate(z, x, y) - ) { - return new Response("Invalid tile coordinate", { status: 400 }); - } - - const normalizedX = normalizeTileX(x, z); - const theme = resolveTileTheme(request); - const upstreams = resolveTileUpstreams(theme); - - let lastStatus = 502; - - for (const template of upstreams) { - const upstreamUrl = buildUpstreamUrl(template, z, normalizedX, y); - try { - const upstreamRes = await fetch(upstreamUrl, { - headers: { - accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", - }, - // Deck.gl already caches tiles on client side; this enables edge cache. - cf: { - cacheEverything: true, - cacheTtl: 60 * 60 * 24 * 30, - }, - }); - - if (!upstreamRes.ok) { - lastStatus = upstreamRes.status; - continue; - } - - const body = await upstreamRes.arrayBuffer(); - return new Response(body, { - status: 200, - headers: { - "content-type": - upstreamRes.headers.get("content-type") || "image/png", - "cache-control": - "public, max-age=2592000, s-maxage=2592000, stale-while-revalidate=2592000", - "access-control-allow-origin": "*", - vary: "Accept", - "x-map-theme": theme, - }, - }); - } catch { - lastStatus = 502; - } - } - - return new Response("Tile upstream unavailable", { status: lastStatus }); + return handleMapTileRequest(request, await context.params); } diff --git a/src/app/api/private/[...segments]/route.ts b/src/app/api/private/[...segments]/route.ts index 62176d3f..43ee0c1c 100644 --- a/src/app/api/private/[...segments]/route.ts +++ b/src/app/api/private/[...segments]/route.ts @@ -1,60 +1,27 @@ -import { handlePrivateAdmin } from "@/lib/edge/admin"; -import { handlePrivateArchive } from "@/lib/edge/archive-query"; -import { handlePrivateQuery } from "@/lib/edge/query"; import { resolveEdgeRuntime } from "@/lib/edge/runtime"; -import type { Env } from "@/lib/edge/types"; +import apiApp from "@/lib/hono/app"; -function routePrivateRequest( - request: Request, - env: Env, - url: URL, - ctx: ExecutionContext, -): Promise { - if (url.pathname.startsWith("/api/private/admin/")) { - return handlePrivateAdmin(request, env, url); - } - if (url.pathname.startsWith("/api/private/archive/")) { - return handlePrivateArchive(request, env, url); - } - return handlePrivateQuery(request, env, url, ctx); -} - -export async function GET(request: Request): Promise { +async function routePrivateRequest(request: Request): Promise { const { request: requestWithCf, env, ctx, - url, } = await resolveEdgeRuntime(request); - return routePrivateRequest(requestWithCf, env, url, ctx); + return apiApp.fetch(requestWithCf, env, ctx); +} + +export async function GET(request: Request): Promise { + return routePrivateRequest(request); } export async function POST(request: Request): Promise { - const { - request: requestWithCf, - env, - ctx, - url, - } = await resolveEdgeRuntime(request); - return routePrivateRequest(requestWithCf, env, url, ctx); + return routePrivateRequest(request); } export async function PATCH(request: Request): Promise { - const { - request: requestWithCf, - env, - ctx, - url, - } = await resolveEdgeRuntime(request); - return routePrivateRequest(requestWithCf, env, url, ctx); + return routePrivateRequest(request); } export async function DELETE(request: Request): Promise { - const { - request: requestWithCf, - env, - ctx, - url, - } = await resolveEdgeRuntime(request); - return routePrivateRequest(requestWithCf, env, url, ctx); + return routePrivateRequest(request); } diff --git a/src/app/api/public/[...segments]/route.ts b/src/app/api/public/[...segments]/route.ts index 83ba293a..5fd7e308 100644 --- a/src/app/api/public/[...segments]/route.ts +++ b/src/app/api/public/[...segments]/route.ts @@ -1,12 +1,23 @@ -import { handlePublicQuery } from "@/lib/edge/query"; import { resolveEdgeRuntime } from "@/lib/edge/runtime"; +import apiApp from "@/lib/hono/app"; export async function GET(request: Request): Promise { const { request: requestWithCf, env, ctx, - url, } = await resolveEdgeRuntime(request); - return handlePublicQuery(requestWithCf, env, url, ctx); + return apiApp.fetch(requestWithCf, env, ctx); +} + +export async function POST(request: Request): Promise { + return GET(request); +} + +export async function PATCH(request: Request): Promise { + return GET(request); +} + +export async function DELETE(request: Request): Promise { + return GET(request); } diff --git a/src/app/api/v1/[[...path]]/route.ts b/src/app/api/v1/[[...path]]/route.ts index bcb6a9a9..1bdc1f7b 100644 --- a/src/app/api/v1/[[...path]]/route.ts +++ b/src/app/api/v1/[[...path]]/route.ts @@ -1,14 +1,13 @@ -import { handleApiV1 } from "@/lib/edge/api-v1"; import { resolveEdgeRuntime } from "@/lib/edge/runtime"; +import apiApp from "@/lib/hono/app"; async function routeApiV1Request(request: Request): Promise { const { request: requestWithCf, env, ctx, - url, } = await resolveEdgeRuntime(request); - return handleApiV1(requestWithCf, env, url, ctx); + return apiApp.fetch(requestWithCf, env, ctx); } export async function GET(request: Request): Promise { diff --git a/src/app/api/v1/__tests__/route.test.ts b/src/app/api/v1/__tests__/route.test.ts index 8000d6f9..fe747247 100644 --- a/src/app/api/v1/__tests__/route.test.ts +++ b/src/app/api/v1/__tests__/route.test.ts @@ -1,140 +1,62 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DELETE, GET, PATCH, POST } from "@/app/api/v1/[[...path]]/route"; -import { handleApiV1 } from "@/lib/edge/api-v1"; import { resolveEdgeRuntime } from "@/lib/edge/runtime"; +import apiApp from "@/lib/hono/app"; vi.mock("@/lib/edge/runtime", () => ({ resolveEdgeRuntime: vi.fn(), })); -vi.mock("@/lib/edge/api-v1", () => ({ - handleApiV1: vi.fn(), +vi.mock("@/lib/hono/app", () => ({ + default: { + fetch: vi.fn(), + }, })); +const apiFetchMock = vi.mocked(apiApp.fetch); const resolveEdgeRuntimeMock = vi.mocked(resolveEdgeRuntime); -const handleApiV1Mock = vi.mocked(handleApiV1); -function makeRequest(method: string, path: string): Request { - return new Request(`https://app.test/api/v1${path}`, { method }); +const env = { DB: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +}; + +function mockRuntime(method: string) { + const request = new Request("https://app.test/api/v1/sites", { method }); + const url = new URL(request.url); + resolveEdgeRuntimeMock.mockResolvedValue({ + request, + env, + ctx, + url, + } as any); + return request; } -describe("api/v1/[...path] route", () => { +describe("API v1 Next route fallback", () => { beforeEach(() => { + apiFetchMock.mockReset(); resolveEdgeRuntimeMock.mockReset(); - handleApiV1Mock.mockReset(); + apiFetchMock.mockResolvedValue(new Response("hono")); }); - it("delegates GET requests to handleApiV1", async () => { - const requestWithCf = makeRequest("GET", "/sites"); - const env = { DB: {} }; - const ctx = { waitUntil: vi.fn() }; - const url = new URL("https://app.test/api/v1/sites"); - - resolveEdgeRuntimeMock.mockResolvedValue({ - request: requestWithCf, - env, - ctx, - url, - } as any); - handleApiV1Mock.mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - const response = await GET(makeRequest("GET", "/sites")); - - expect(resolveEdgeRuntimeMock).toHaveBeenCalledWith(expect.any(Request)); - expect(handleApiV1Mock).toHaveBeenCalledWith(requestWithCf, env, url, ctx); - expect(response.status).toBe(200); - }); - - it("delegates POST requests to handleApiV1", async () => { - const requestWithCf = makeRequest("POST", "/sites"); - const env = { DB: {} }; - const ctx = { waitUntil: vi.fn() }; - const url = new URL("https://app.test/api/v1/sites"); - - resolveEdgeRuntimeMock.mockResolvedValue({ - request: requestWithCf, - env, - ctx, - url, - } as any); - handleApiV1Mock.mockResolvedValue(new Response(null, { status: 201 })); - - const response = await POST(makeRequest("POST", "/sites")); - - expect(handleApiV1Mock).toHaveBeenCalledWith(requestWithCf, env, url, ctx); - expect(response.status).toBe(201); - }); - - it("delegates PATCH requests to handleApiV1", async () => { - const requestWithCf = makeRequest("PATCH", "/sites/s1"); - const env = { DB: {} }; - const ctx = { waitUntil: vi.fn() }; - const url = new URL("https://app.test/api/v1/sites/s1"); - - resolveEdgeRuntimeMock.mockResolvedValue({ - request: requestWithCf, - env, - ctx, - url, - } as any); - handleApiV1Mock.mockResolvedValue(new Response(null, { status: 200 })); - - const response = await PATCH(makeRequest("PATCH", "/sites/s1")); - - expect(handleApiV1Mock).toHaveBeenCalledWith(requestWithCf, env, url, ctx); - expect(response.status).toBe(200); - }); - - it("delegates DELETE requests to handleApiV1", async () => { - const requestWithCf = makeRequest("DELETE", "/sites/s1"); - const env = { DB: {} }; - const ctx = { waitUntil: vi.fn() }; - const url = new URL("https://app.test/api/v1/sites/s1"); - - resolveEdgeRuntimeMock.mockResolvedValue({ - request: requestWithCf, - env, - ctx, - url, - } as any); - handleApiV1Mock.mockResolvedValue(new Response(null, { status: 204 })); - - const response = await DELETE(makeRequest("DELETE", "/sites/s1")); - - expect(handleApiV1Mock).toHaveBeenCalledWith(requestWithCf, env, url, ctx); - expect(response.status).toBe(204); - }); - - it("propagates errors from handleApiV1", async () => { - resolveEdgeRuntimeMock.mockResolvedValue({ - request: makeRequest("GET", "/bad"), - env: {}, - ctx: {}, - url: new URL("https://app.test/api/v1/bad"), - } as any); - handleApiV1Mock.mockResolvedValue( - new Response( - JSON.stringify({ ok: false, error: { code: "not_found" } }), - { status: 404 }, - ), - ); - - const response = await GET(makeRequest("GET", "/bad")); - - expect(response.status).toBe(404); - }); - - it("propagates runtime resolution errors", async () => { - resolveEdgeRuntimeMock.mockRejectedValue(new Error("no cloudflare ctx")); - - await expect(GET(makeRequest("GET", "/sites"))).rejects.toThrow( - "no cloudflare ctx", - ); - }); + it.each([ + ["GET", GET], + ["POST", POST], + ["PATCH", PATCH], + ["DELETE", DELETE], + ])( + "delegates %s requests to the shared Hono app", + async (method, handler) => { + const request = mockRuntime(method); + + const response = await handler(request); + + expect(await response.text()).toBe("hono"); + expect(resolveEdgeRuntimeMock).toHaveBeenCalledWith(request); + expect(apiFetchMock).toHaveBeenCalledWith(expect.any(Request), env, ctx); + }, + ); }); diff --git a/src/app/collect/route.ts b/src/app/collect/route.ts index 0ae037ef..693c6fb0 100644 --- a/src/app/collect/route.ts +++ b/src/app/collect/route.ts @@ -1,594 +1,19 @@ -import { isBot } from "ua-parser-js/bot-detection"; - -import { normalizeTrackerUaClientHints } from "@/lib/edge/client-hints"; -import { expandCustomEventData } from "@/lib/edge/custom-event-json"; -import { resolveEdgeRuntime } from "@/lib/edge/runtime"; import { - normalizeSiteSettingsKey, - readSiteTrackingConfig, -} from "@/lib/edge/site-settings-store"; -import type { - IngestEnvelopePayload, - IngestTracePayload, - SerializedRequestPayload, - TrackerClientPayload, -} from "@/lib/edge/types"; -import type { TrackerPayloadKind } from "@/lib/edge/types"; -import { jsonCloneRecord } from "@/lib/edge/utils"; -import { assertContentSize, BODY_SIZE_LIMITS } from "@/lib/form-helpers"; -import { jsonResponse } from "@/lib/response"; -import type { SiteTrackingConfig } from "@/lib/site-settings"; - -const CORS_BASE_HEADERS = { - "access-control-allow-methods": "GET, POST, PATCH, OPTIONS", - "access-control-allow-headers": "content-type", - "access-control-max-age": "86400", -}; - -const SUPPORTED_KINDS = new Set([ - "pageview", - "leave", - "visibility", - "custom_event", - "identify", -]); - -function pickSiteIdFromPayload( - payload: TrackerClientPayload, - requestUrl: URL, -): string { - if (typeof payload.siteId === "string" && payload.siteId.length > 0) { - return payload.siteId; - } - const fromQuery = requestUrl.searchParams.get("siteId"); - if (fromQuery && fromQuery.length > 0) { - return fromQuery; - } - return "default"; -} - -function sanitizeInputPayload(payload: unknown): TrackerClientPayload | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return null; - } - return payload as TrackerClientPayload; -} - -function coerceTrimmedString(input: unknown, maxLength: number): string { - if (typeof input !== "string") return ""; - return input.trim().slice(0, maxLength); -} - -function isSupportedKind(input: unknown): input is TrackerPayloadKind { - return ( - typeof input === "string" && - SUPPORTED_KINDS.has(input as TrackerPayloadKind) - ); -} - -function normalizeClientHostname(input: unknown): string { - const value = coerceTrimmedString(input, 255) - .toLowerCase() - .replace(/\.+$/, ""); - if (!value || value.includes("/") || value.includes(":")) return ""; - return value; -} - -function normalizePayloadPathname(input: unknown): string { - let value = coerceTrimmedString(input, 4096); - if (!value) value = "/"; - - if (value.includes("://")) { - try { - value = new URL(value).pathname || "/"; - } catch { - return ""; - } - } - - value = value.split(/[?#]/)[0] ?? value; - value = value.trim().replace(/\s+/g, ""); - if (!value) value = "/"; - if (!value.startsWith("/")) value = `/${value.replace(/^\/+/, "")}`; - value = value.replace(/\/{2,}/g, "/"); - return value.slice(0, 2048); -} - -function matchesBlockedPath(pathname: string, blockedPaths: string[]): boolean { - for (const blockedPath of blockedPaths) { - if (!blockedPath) continue; - if (pathname === blockedPath || pathname.startsWith(`${blockedPath}/`)) { - return true; - } - } - return false; -} - -function serializeHeaders(request: Request): Record { - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); - return headers; -} - -function serializeRequestPayload( - request: Request, - body: string, -): SerializedRequestPayload { - return { - method: request.method, - url: request.url, - headers: serializeHeaders(request), - cf: jsonCloneRecord((request as Request & { cf?: unknown }).cf), - body, - receivedAt: Date.now(), - }; -} - -function parseOrigin(request: Request): string | null { - const raw = (request.headers.get("origin") || "").trim(); - if (!raw) return null; - try { - return new URL(raw).origin; - } catch { - return null; - } -} - -function parseOriginHostname(origin: string | null): string { - if (!origin) return ""; - try { - return new URL(origin).hostname.trim().toLowerCase().replace(/\.+$/, ""); - } catch { - return ""; - } -} - -function toCorsHeaders(origin: string | null): Record { - if (!origin) { - return { - ...CORS_BASE_HEADERS, - vary: "Origin", - }; - } - return { - ...CORS_BASE_HEADERS, - "access-control-allow-origin": origin, - vary: "Origin", - }; -} - -function isBotRequest(request: Request): boolean { - const ua = request.headers.get("user-agent") || ""; - if (!ua || !isBot(ua)) return false; - console.log(`[Bot] UA: ${ua}`); - return true; -} - -type CollectionDecision = - | { - shouldForward: false; - allowOrigin: string | null; - siteId: string; - payload: null; - reason: string; - detail?: Record; - } - | { - shouldForward: true; - allowOrigin: string | null; - siteId: string; - payload: TrackerClientPayload; - }; - -async function decideCollectionPolicy( - request: Request, - env: Awaited>["env"], - payload: TrackerClientPayload | null, - requestUrl: URL, -): Promise { - const origin = parseOrigin(request); - const originHostname = parseOriginHostname(origin); - if (!payload) { - return { - shouldForward: false, - allowOrigin: origin, - siteId: "", - payload: null, - reason: "missing_payload", - }; - } - - const kind = payload.kind; - if (!isSupportedKind(kind)) { - return { - shouldForward: false, - allowOrigin: origin, - siteId: "", - payload: null, - reason: "unsupported_kind", - detail: { kind: String(kind || "") }, - }; - } - - const siteId = normalizeSiteSettingsKey( - pickSiteIdFromPayload(payload, requestUrl), - ); - if (!siteId) { - return { - shouldForward: false, - allowOrigin: origin, - siteId: "", - payload: null, - reason: "missing_site_id", - }; - } - - let settings = null; - try { - // `readSiteTrackingConfig` already caches KV results for 1 hour. - settings = await readSiteTrackingConfig(env, siteId); - } catch (error) { - logIngestTrace("collect_settings_read_failed", { - siteId, - error: errorToMessage(error), - }); - settings = null; - } - - if (!settings?.siteDomain) { - return { - shouldForward: false, - allowOrigin: origin, - siteId, - payload: null, - reason: "missing_site_settings", - }; - } - - const hasWhitelist = - Array.isArray(settings.domainWhitelist) && - settings.domainWhitelist.length > 0; - if ( - hasWhitelist && - !settings.allowedHostnames.some( - (hostname) => hostname.trim().toLowerCase() === originHostname, - ) - ) { - return { - shouldForward: false, - allowOrigin: origin, - siteId, - payload: null, - reason: "origin_not_allowed", - detail: { - origin, - originHostname, - allowedHostnames: settings.allowedHostnames, - }, - }; - } - - const normalizedPayloadResult = normalizeForwardPayload( - payload, - siteId, - kind, - settings, - ); - if (!normalizedPayloadResult.payload) { - return { - shouldForward: false, - allowOrigin: origin, - siteId, - payload: null, - reason: normalizedPayloadResult.reason, - detail: normalizedPayloadResult.detail, - }; - } - - return { - shouldForward: true, - allowOrigin: origin, - siteId, - payload: normalizedPayloadResult.payload, - }; -} - -function normalizeForwardPayload( - payload: TrackerClientPayload, - siteId: string, - kind: TrackerPayloadKind, - settings: SiteTrackingConfig, -): { - payload: TrackerClientPayload | null; - reason: string; - detail?: Record; -} { - const visitId = coerceTrimmedString(payload.visitId, 128); - if (!visitId) return { payload: null, reason: "missing_visit_id" }; - - const normalizedPayload: TrackerClientPayload = { - ...payload, - siteId, - kind, - visitId, - }; - const uaClientHints = normalizeTrackerUaClientHints(payload.uaClientHints); - if (uaClientHints) { - normalizedPayload.uaClientHints = uaClientHints; - } else { - delete normalizedPayload.uaClientHints; - } - - const canCheckPath = - kind === "pageview" || - kind === "custom_event" || - kind === "visibility" || - (kind === "leave" && - coerceTrimmedString(payload.pathname, 4096).length > 0); - - if (canCheckPath) { - const pathname = normalizePayloadPathname(payload.pathname); - if (!pathname) { - return { - payload: null, - reason: "invalid_pathname", - detail: { pathname: String(payload.pathname || "") }, - }; - } - if (matchesBlockedPath(pathname, settings.pathBlacklist)) { - return { - payload: null, - reason: "blocked_pathname", - detail: { pathname }, - }; - } - normalizedPayload.pathname = pathname; - } - - if (kind === "pageview") { - const hostname = normalizeClientHostname(payload.hostname); - if (!hostname) { - return { - payload: null, - reason: "missing_hostname", - detail: { hostname: String(payload.hostname || "") }, - }; - } - normalizedPayload.hostname = hostname; - } - - if (kind === "custom_event") { - const eventName = coerceTrimmedString(payload.eventName, 120); - if (!eventName) return { payload: null, reason: "missing_event_name" }; - normalizedPayload.eventName = eventName; - } - - if (kind === "visibility") { - const visibilityState = coerceTrimmedString(payload.visibilityState, 20); - if (visibilityState !== "hidden" && visibilityState !== "visible") { - return { - payload: null, - reason: "invalid_visibility_state", - detail: { visibilityState }, - }; - } - normalizedPayload.visibilityState = visibilityState; - } - - return { payload: normalizedPayload, reason: "" }; -} - -function createTraceId(): string { - try { - return crypto.randomUUID(); - } catch { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - } -} - -function errorToMessage(error: unknown): string { - return String(error instanceof Error ? error.message : error); -} - -function logIngestTrace( - event: string, - fields: Record = {}, - level: "info" | "warn" | "error" = "info", -): void { - const payload = { - event, - at: new Date().toISOString(), - ...fields, - }; - const line = JSON.stringify(payload); - if (level === "error") { - console.error(line); - return; - } - if (level === "warn") { - console.warn(line); - return; - } - console.log(line); -} - -function compactPayloadForLog( - payload: TrackerClientPayload | null, -): Record { - if (!payload) return {}; - return { - kind: payload.kind || "", - siteId: payload.siteId || "", - visitId: payload.visitId || "", - previousVisitId: payload.previousVisitId || "", - eventId: payload.eventId || "", - eventName: payload.eventName || "", - visibilityState: payload.visibilityState || "", - pathname: payload.pathname || "", - hostname: payload.hostname || "", - timestamp: payload.timestamp ?? null, - }; -} - -function noContent(origin: string | null): Response { - return new Response(null, { status: 204, headers: toCorsHeaders(origin) }); -} - -function jsonError( - origin: string | null, - message: string, - status: 400 | 413 | 422 = 400, -): Response { - return jsonResponse( - { ok: false, error: message }, - status, - toCorsHeaders(origin), - ); -} + handleCollectOptionsRequest, + handleCollectRequest, +} from "@/lib/edge/collect"; +import { resolveEdgeRuntime } from "@/lib/edge/runtime"; export async function OPTIONS(request: Request): Promise { - return noContent(parseOrigin(request)); + return handleCollectOptionsRequest(request); } export async function POST(request: Request): Promise { - // Body 大小限制检查 - const sizeError = assertContentSize(request, BODY_SIZE_LIMITS.COLLECT); - if (sizeError) return sizeError; - const { env, ctx, request: requestWithCf, url, } = await resolveEdgeRuntime(request); - const origin = parseOrigin(requestWithCf); - const trace: IngestTracePayload = { - id: createTraceId(), - source: "collect", - acceptedAt: Date.now(), - }; - - if (isBotRequest(requestWithCf)) { - logIngestTrace("collect_rejected", { - traceId: trace.id, - reason: "bot", - origin, - userAgent: requestWithCf.headers.get("user-agent") || "", - }); - return noContent(origin); - } - - const body = await requestWithCf.text(); - let payload: TrackerClientPayload | null = null; - if (body) { - try { - payload = sanitizeInputPayload(JSON.parse(body)); - } catch (error) { - logIngestTrace( - "collect_rejected", - { - traceId: trace.id, - reason: "invalid_json", - origin, - bodyBytes: body.length, - error: errorToMessage(error), - }, - "warn", - ); - return jsonError(origin, "Invalid JSON payload", 400); - } - } - - if (payload?.kind === "custom_event") { - const eventDataResult = expandCustomEventData(payload.eventData); - if (!eventDataResult.ok) { - logIngestTrace( - "collect_rejected", - { - traceId: trace.id, - reason: "invalid_custom_event_data", - ...compactPayloadForLog(payload), - error: eventDataResult.error, - }, - "warn", - ); - return jsonError(origin, eventDataResult.error, eventDataResult.status); - } - } - - const decision = await decideCollectionPolicy( - requestWithCf, - env, - payload, - url, - ); - if (!decision.shouldForward) { - logIngestTrace("collect_rejected", { - traceId: trace.id, - reason: decision.reason, - origin, - siteId: decision.siteId, - ...compactPayloadForLog(payload), - ...(decision.detail || {}), - }); - return noContent(decision.allowOrigin); - } - - const doId = env.INGEST_DO.idFromName(decision.siteId); - const stub = env.INGEST_DO.get(doId); - - const envelope: IngestEnvelopePayload = { - request: serializeRequestPayload(requestWithCf, body), - client: decision.payload, - trace, - }; - - logIngestTrace("collect_forward_queued", { - traceId: trace.id, - origin, - ...compactPayloadForLog(decision.payload), - }); - - ctx.waitUntil( - stub - .fetch("https://ingest.internal/ingest", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(envelope), - }) - .then(async (response) => { - const bodyText = await response.text().catch(() => ""); - logIngestTrace( - response.ok ? "collect_forward_result" : "collect_forward_failed", - { - traceId: trace.id, - siteId: decision.siteId, - kind: decision.payload.kind || "", - visitId: decision.payload.visitId || "", - status: response.status, - response: bodyText.slice(0, 200), - }, - response.ok ? "info" : "error", - ); - }) - .catch((error: unknown) => { - logIngestTrace( - "collect_forward_failed", - { - traceId: trace.id, - siteId: decision.siteId, - kind: decision.payload.kind || "", - visitId: decision.payload.visitId || "", - error: errorToMessage(error), - }, - "error", - ); - }), - ); - - return noContent(decision.allowOrigin); + return handleCollectRequest(requestWithCf, env, ctx, url); } diff --git a/src/components/dashboard/api-keys-client.tsx b/src/components/dashboard/api-keys-client.tsx index b5ec5cbc..f38d53cd 100644 --- a/src/components/dashboard/api-keys-client.tsx +++ b/src/components/dashboard/api-keys-client.tsx @@ -20,6 +20,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { AutoTransition } from "@/components/ui/auto-transition"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -41,6 +42,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -326,143 +328,194 @@ export function ApiKeysClient({ - {loading ? ( -

- {copy.loading} -

- ) : keys.length === 0 ? ( -
- -

{copy.empty}

-
- ) : ( - - - - {copy.columns.name} - {copy.columns.scopes} - {copy.columns.sites} - {copy.columns.expires} - {copy.columns.lastUsed} - {copy.columns.status} - - {copy.columns.action} - - - - - {keys.map((key) => ( - - -
{key.name}
-
- {key.prefix} -
-
- -
- {key.scopes.map((scope) => ( - - {scopeLabel(copy, scope)} - - ))} -
-
- - {siteScopeLabel(key)} - - - {key.expiresAt - ? dateTime(locale, key.expiresAt) - : copy.neverExpires} - - - {key.lastUsedAt - ? dateTime(locale, key.lastUsedAt) - : copy.notUsed} - - - - {copy.status[key.status]} - - - -
- - - - - - - {copy.rotate} - - {copy.rotateConfirm} - - - - - {cancelLabel} - - void rotateKey(key.id)} + + {loading ? ( +

+ {copy.loading} +

+ ) : keys.length === 0 ? ( +
+ +

{copy.empty}

+
+ ) : ( +
+ + + {copy.columns.name} + {copy.columns.scopes} + {copy.columns.sites} + {copy.columns.expires} + {copy.columns.lastUsed} + {copy.columns.status} + + {copy.columns.action} + + + + + {keys.map((key) => ( + + +
{key.name}
+
+ {key.prefix} +
+
+ +
+ {key.scopes.map((scope) => ( + + {scopeLabel(copy, scope)} + + ))} +
+
+ + {siteScopeLabel(key)} + + + {key.expiresAt + ? dateTime(locale, key.expiresAt) + : copy.neverExpires} + + + {key.lastUsedAt + ? dateTime(locale, key.lastUsedAt) + : copy.notUsed} + + + + {copy.status[key.status]} + + + +
+ + + - - - - {copy.revoke} - - {copy.revokeConfirm} - - - - - {cancelLabel} - - + {busyKeyId === key.id ? ( + + + {copy.rotate} + + ) : ( + + + {copy.rotate} + + )} + + + + + + + {copy.rotate} + + + {copy.rotateConfirm} + + + + + {cancelLabel} + + void rotateKey(key.id)} + > + {copy.rotate} + + + + + + +
-
-
- ))} -
-
- )} + + {busyKeyId === key.id ? ( + + + {copy.revoke} + + ) : ( + {copy.revoke} + )} + + + + + + + {copy.revoke} + + + {copy.revokeConfirm} + + + + + {cancelLabel} + + void revokeKey(key.id)} + > + {copy.revoke} + + + + + + + + ))} + + + )} +
@@ -565,7 +618,19 @@ export function ApiKeysClient({ diff --git a/src/components/dashboard/dashboard-header-controls.tsx b/src/components/dashboard/dashboard-header-controls.tsx index 45740a0a..cad55727 100644 --- a/src/components/dashboard/dashboard-header-controls.tsx +++ b/src/components/dashboard/dashboard-header-controls.tsx @@ -118,6 +118,7 @@ interface DashboardHeaderControlsProps { siteId?: string; showControls: boolean; showFilterSheet: boolean; + showRealtimeBadge?: boolean; } const FILTER_QUERY_KEYS = [ @@ -1015,6 +1016,7 @@ export function DashboardHeaderControls({ siteId, showControls, showFilterSheet, + showRealtimeBadge: shouldShowRealtimeBadge = true, }: DashboardHeaderControlsProps) { const searchParams = useLiveSearchParams(); const livePathname = usePathname() || "/"; @@ -1028,6 +1030,7 @@ export function DashboardHeaderControls({ setUiFilters, allowedIntervals, timeZone, + maxRangeDays, } = useDashboardQueryControls(); const searchParamsKey = searchParams.toString(); const queryFilters = useMemo( @@ -1075,7 +1078,9 @@ export function DashboardHeaderControls({ const realtimeSiteId = siteId || (USE_REALTIME_MOCK ? "local-mock-site" : undefined); const showRealtimeBadge = - showFilterSheet && (Boolean(siteId) || USE_REALTIME_MOCK); + shouldShowRealtimeBadge && + showFilterSheet && + (Boolean(siteId) || USE_REALTIME_MOCK); const realtime = useRealtimeChannel(realtimeSiteId, { enabled: showControls && showRealtimeBadge, }); @@ -1086,6 +1091,14 @@ export function DashboardHeaderControls({ const orderedAllowedIntervals = INTERVAL_ORDER.filter((value) => allowedIntervals.includes(value), ); + const rangeGroups = useMemo( + () => + RANGE_GROUPS.map((group) => ({ + ...group, + items: group.items.filter((item) => !(maxRangeDays && item === "all")), + })).filter((group) => group.items.length > 0), + [maxRangeDays], + ); const rangeLabelText = rangeLabel(messages, range); const intervalLabelText = intervalLabel(messages, window.interval); const pendingNormalized = normalizeCustomDateRange( @@ -1278,7 +1291,7 @@ export function DashboardHeaderControls({ className={filterTriggerClassName} style={filterTriggerStyle} > - + {messages.dashboardHeader.filters} @@ -1367,7 +1380,7 @@ export function DashboardHeaderControls({
- {RANGE_GROUPS.map((group) => ( + {rangeGroups.map((group) => (

{rangeGroupLabel(messages, group.key)} @@ -1450,7 +1463,7 @@ export function DashboardHeaderControls({ className={filterTriggerClassName} style={filterTriggerStyle} > - + {messages.dashboardHeader.filters} @@ -1538,7 +1551,7 @@ export function DashboardHeaderControls({ - {RANGE_GROUPS.map((group, groupIndex) => ( + {rangeGroups.map((group, groupIndex) => (

{groupIndex > 0 ? : null} diff --git a/src/components/dashboard/dashboard-query-provider.tsx b/src/components/dashboard/dashboard-query-provider.tsx index fa230425..e8fdc3fc 100644 --- a/src/components/dashboard/dashboard-query-provider.tsx +++ b/src/components/dashboard/dashboard-query-provider.tsx @@ -52,12 +52,14 @@ interface DashboardQueryContextValue { timeZonePreference: string; browserTimeZone: string; setTimeZonePreference: (timeZone: string) => void; + maxRangeDays?: number; } interface DashboardQueryProviderProps { children: ReactNode; scopeKey?: string; initialTimeZonePreference?: string; + maxRangeDays?: number; } const STORAGE_KEY = "insightflare.dashboard.query.v2"; @@ -178,21 +180,46 @@ function buildInitialState(initialTimeZonePreference: string) { }; } +function clampCustomRangeToMaxDays( + range: CustomTimeRange | null, + maxRangeDays?: number, +): CustomTimeRange | null { + if (!range || !maxRangeDays) return range; + const maxSpan = maxRangeDays * 24 * 60 * 60 * 1000; + if (range.to - range.from <= maxSpan) return range; + return { + from: Math.max(0, range.to - maxSpan), + to: range.to, + }; +} + +function clampPresetForMaxDays( + range: RangePreset, + maxRangeDays?: number, +): RangePreset { + if (!maxRangeDays) return range; + if (range === "all") return "12m"; + return range; +} + export function DashboardQueryProvider({ children, scopeKey = "", initialTimeZonePreference = "", + maxRangeDays, }: DashboardQueryProviderProps) { const initial = useMemo( () => buildInitialState(initialTimeZonePreference), [initialTimeZonePreference], ); - const [range, setRangeState] = useState(initial.range); + const [range, setRangeState] = useState( + clampPresetForMaxDays(initial.range, maxRangeDays), + ); const [interval, setIntervalState] = useState( initial.interval, ); const [customRange, setCustomRangeState] = useState( - initial.customRange, + clampCustomRangeToMaxDays(initial.customRange, maxRangeDays), ); const [uiFilters, setUiFiltersState] = useState( initial.uiFilters, @@ -209,12 +236,16 @@ export function DashboardQueryProvider({ const windowState = useMemo( () => - resolveTimeWindow(range, Date.now(), { - customRange: customRange || undefined, - interval, - timeZone, - }), - [range, customRange, interval, timeZone], + resolveTimeWindow( + clampPresetForMaxDays(range, maxRangeDays), + Date.now(), + { + customRange: customRange || undefined, + interval, + timeZone, + }, + ), + [range, maxRangeDays, customRange, interval, timeZone], ); useEffect(() => { @@ -253,29 +284,38 @@ export function DashboardQueryProvider({ const setRange = useCallback( (next: RangePreset) => { + const clampedNext = clampPresetForMaxDays(next, maxRangeDays); if (next === "custom" && !customRange) { - setRangeState(next); + setRangeState(clampedNext); return; } - const nextWindow = resolveTimeWindow(next, Date.now(), { + const nextWindow = resolveTimeWindow(clampedNext, Date.now(), { customRange: customRange || undefined, interval: null, timeZone, }); - setRangeState(next); + setRangeState(clampedNext); setIntervalState(finestIntervalForRange(nextWindow.from, nextWindow.to)); }, - [customRange, timeZone], + [customRange, maxRangeDays, timeZone], ); - const setCustomRange = useCallback((next: CustomTimeRange | null) => { - const normalized = normalizeCustomRange(next); - setCustomRangeState(normalized); - if (normalized) { - setRangeState("custom"); - setIntervalState(finestIntervalForRange(normalized.from, normalized.to)); - } - }, []); + const setCustomRange = useCallback( + (next: CustomTimeRange | null) => { + const normalized = clampCustomRangeToMaxDays( + normalizeCustomRange(next), + maxRangeDays, + ); + setCustomRangeState(normalized); + if (normalized) { + setRangeState("custom"); + setIntervalState( + finestIntervalForRange(normalized.from, normalized.to), + ); + } + }, + [maxRangeDays], + ); const setInterval = useCallback((next: DashboardInterval) => { setIntervalState(next); @@ -315,6 +355,7 @@ export function DashboardQueryProvider({ timeZonePreference, browserTimeZone: detectedBrowserTimeZone, setTimeZonePreference, + maxRangeDays, }), [ range, @@ -331,6 +372,7 @@ export function DashboardQueryProvider({ timeZonePreference, detectedBrowserTimeZone, setTimeZonePreference, + maxRangeDays, ], ); @@ -361,6 +403,7 @@ function useDashboardQueryContext(): DashboardQueryContextValue { timeZonePreference: "", browserTimeZone: "", setTimeZonePreference: () => {}, + maxRangeDays: undefined, }; } return context; diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 6d1a67ab..08f9b8b3 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -327,6 +327,39 @@ export function DashboardShell({ const mainSiteSection = mainLayoutSegments[1] || ""; const mainSiteSubSection = mainLayoutSegments[2] || ""; const routeState = parseSidebarRouteState(livePathname, activeTeamSlug); + + // Derive current analytics section from the live pathname directly + // (more reliable than useSelectedLayoutSegments which depends on layout nesting) + const VALID_ANALYTICS_SECTIONS = new Set([ + "realtime", + "pages", + "referrers", + "sessions", + "events", + "visitors", + "geo", + "devices", + "browsers", + "performance", + "settings", + "campaigns", + "funnels", + "retention", + ]); + const currentAnalyticsSection = (() => { + if (routeState.mode !== "site" || !routeState.activeSiteSlug) + return undefined; + const segments = livePathname.split("/").filter((s) => s.length > 0); + const teamIndex = segments.findIndex( + (segment, index) => + segment === activeTeamSlug && + index > 0 && + segments[index - 1] === "app", + ); + const localPath = teamIndex >= 0 ? segments.slice(teamIndex + 1) : []; + const section = localPath[1] || ""; + return VALID_ANALYTICS_SECTIONS.has(section) ? section : undefined; + })(); const hasManagementSections = Boolean( managementSections && managementSections.length > 0, ); @@ -575,6 +608,7 @@ export function DashboardShell({ teamId={activeTeamId} teamSlug={activeTeamSlug} activeSiteSlug={resolvedActiveSiteSlug} + currentSection={currentAnalyticsSection} sites={sites.map((site) => ({ id: site.id, slug: site.slug, diff --git a/src/components/dashboard/geo-points-map-island.tsx b/src/components/dashboard/geo-points-map-island.tsx index 1bbbb0ec..c873923d 100644 --- a/src/components/dashboard/geo-points-map-island.tsx +++ b/src/components/dashboard/geo-points-map-island.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; +import { Spinner } from "@/components/ui/spinner"; import type { Locale } from "@/lib/i18n/config"; import type { AppMessages } from "@/lib/i18n/messages"; @@ -36,7 +37,7 @@ const GeoPointsMapClient = dynamic( function GeoPointsMapFallback() { return (
-
+
); } diff --git a/src/components/dashboard/geo-points-map.tsx b/src/components/dashboard/geo-points-map.tsx index ad0539c2..47021714 100644 --- a/src/components/dashboard/geo-points-map.tsx +++ b/src/components/dashboard/geo-points-map.tsx @@ -24,6 +24,7 @@ export interface GeoPointsMapPoint { latitude: number; longitude: number; country: string; + pointCount?: number; } export interface GeoPointsMapCountryCount { @@ -329,9 +330,11 @@ function clusterGeoPoints( sumLatitude: 0, sumLongitude: 0, }; - bucket.count += 1; - bucket.sumLatitude += point.latitude; - bucket.sumLongitude += point.longitude; + // Use pointCount if available, otherwise count as 1 + const pointWeight = point.pointCount ?? 1; + bucket.count += pointWeight; + bucket.sumLatitude += point.latitude * pointWeight; + bucket.sumLongitude += point.longitude * pointWeight; buckets.set(key, bucket); } diff --git a/src/components/dashboard/overview-geo-points-map-card.tsx b/src/components/dashboard/overview-geo-points-map-card.tsx index f9eb21ee..75693b1f 100644 --- a/src/components/dashboard/overview-geo-points-map-card.tsx +++ b/src/components/dashboard/overview-geo-points-map-card.tsx @@ -93,6 +93,7 @@ export function OverviewGeoPointsMapCard({ latitude: Number(item.latitude), longitude: Number(item.longitude), country: String(item.country ?? ""), + pointCount: Math.max(1, Number(item.pointCount ?? 1)), })), [geoPointsData.data], ); diff --git a/src/components/dashboard/public-link-copy-button.tsx b/src/components/dashboard/public-link-copy-button.tsx new file mode 100644 index 00000000..639e8f84 --- /dev/null +++ b/src/components/dashboard/public-link-copy-button.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { RiFileCopyLine } from "@remixicon/react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; + +interface PublicLinkCopyButtonProps { + value: string; + label: string; + copiedLabel: string; +} + +export function PublicLinkCopyButton({ + value, + label, + copiedLabel, +}: PublicLinkCopyButtonProps) { + return ( + + ); +} diff --git a/src/components/dashboard/scheduled-tasks-client.tsx b/src/components/dashboard/scheduled-tasks-client.tsx index 1b22bb1e..129da073 100644 --- a/src/components/dashboard/scheduled-tasks-client.tsx +++ b/src/components/dashboard/scheduled-tasks-client.tsx @@ -192,16 +192,25 @@ function HealthCell({ {label}

-

- {value} -

+

+ {value} +

+

{detail}

@@ -898,17 +907,25 @@ export function ScheduledTasksClient({ disabled={replacingRows} onClick={() => setRefreshNonce((value) => value + 1)} > - {replacingRows ? ( - <> - - {messages.common.loading} - - ) : ( - <> - - {t.refresh} - - )} + + {replacingRows ? ( + + + {messages.common.loading} + + ) : ( + + + {t.refresh} + + )} + } diff --git a/src/components/dashboard/share-dashboard-shell.tsx b/src/components/dashboard/share-dashboard-shell.tsx new file mode 100644 index 00000000..88da31cf --- /dev/null +++ b/src/components/dashboard/share-dashboard-shell.tsx @@ -0,0 +1,88 @@ +"use client"; + +import type { ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +import { AnalyticsTabs } from "@/components/dashboard/analytics-tabs"; +import { DashboardQueryProvider } from "@/components/dashboard/dashboard-query-provider"; +import { ShareHeader } from "@/components/dashboard/share-header"; +import { PageTransition } from "@/components/page-transition"; +import { publicDashboardSiteId } from "@/lib/dashboard/client-request"; +import type { Locale } from "@/lib/i18n/config"; +import type { AppMessages } from "@/lib/i18n/messages"; + +interface ShareDashboardShellProps { + locale: Locale; + messages: AppMessages; + slug: string; + siteName: string; + children: ReactNode; +} + +const SHARE_TABS = [ + "overview", + "pages", + "referrers", + "campaigns", + "retention", + "geo", + "devices", + "browsers", + "performance", +] as const; + +function shareTabHref(locale: Locale, slug: string, key: string): string { + const base = `/${locale}/share/${encodeURIComponent(slug)}`; + return key === "overview" ? base : `${base}/${key}`; +} + +export function ShareDashboardShell({ + locale, + messages, + slug, + siteName, + children, +}: ShareDashboardShellProps) { + const publicSiteId = publicDashboardSiteId(slug); + const pathname = usePathname() || ""; + const isGeoRoute = pathname.endsWith("/geo"); + const rootClassName = isGeoRoute + ? "flex h-svh min-h-0 flex-col bg-background text-foreground" + : "min-h-svh bg-background text-foreground"; + const contentClassName = isGeoRoute + ? "flex min-h-0 w-full min-w-0 flex-1 flex-col md:overflow-hidden [&>[data-page-transition]]:flex [&>[data-page-transition]]:h-full [&>[data-page-transition]]:min-h-0 [&>[data-page-transition]]:flex-1 [&>[data-page-transition]]:flex-col" + : "mx-auto w-full max-w-[1400px] p-4 md:p-6"; + + return ( + +
+
+
+ +
+
+ ({ + key, + href: shareTabHref(locale, slug, key), + label: messages.navigation[key], + }))} + /> +
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/components/dashboard/share-header.tsx b/src/components/dashboard/share-header.tsx new file mode 100644 index 00000000..0079b227 --- /dev/null +++ b/src/components/dashboard/share-header.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { + RiArrowDownSLine, + RiComputerLine, + RiGlobalLine, + RiMoonLine, + RiSunLine, +} from "@remixicon/react"; + +import { DashboardHeaderControls } from "@/components/dashboard/dashboard-header-controls"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Locale } from "@/lib/i18n/config"; +import type { AppMessages } from "@/lib/i18n/messages"; + +interface ShareHeaderProps { + locale: Locale; + messages: AppMessages; + publicSiteId: string; + siteName: string; +} + +function localeSwitchPath(pathname: string, locale: Locale): string { + const withoutLocale = pathname.replace(/^\/(en|zh)(?=\/|$)/, "") || "/"; + return `/${locale}${withoutLocale}`; +} + +function pickThemeIcon(theme: string) { + if (theme === "dark") return RiMoonLine; + if (theme === "light") return RiSunLine; + return RiComputerLine; +} + +export function ShareHeader({ + locale, + messages, + publicSiteId, + siteName, +}: ShareHeaderProps) { + const pathname = usePathname() || `/${locale}`; + const router = useRouter(); + const { theme, resolvedTheme, setTheme } = useTheme(); + const themeValue = + theme === "light" || theme === "dark" || theme === "system" + ? theme + : "system"; + const currentTheme = resolvedTheme === "dark" ? "dark" : "light"; + const ThemeIcon = pickThemeIcon( + themeValue === "system" ? currentTheme : themeValue, + ); + const [themeDrawerOpen, setThemeDrawerOpen] = useState(false); + const [languageDrawerOpen, setLanguageDrawerOpen] = useState(false); + const switchLocale = (nextLocale: Locale) => { + if (nextLocale !== locale) { + router.push(localeSwitchPath(pathname, nextLocale)); + } + }; + + return ( +
+
+ + + + + + InsightFlare + + InsightFlare{" "} + V1 + + + + + + + + + {siteName} + + + + +
+ +
+ + + + + + + {messages.common.theme} + +
+ + + + + + + + + +
+
+
+ + + + + + + {messages.common.theme} + + { + if ( + value === "light" || + value === "dark" || + value === "system" + ) { + setTheme(value); + } + }} + > + + + {messages.actions.switchToLight} + + + + {messages.actions.switchToDark} + + + + {messages.common.system} + + + + + + + + + + + + {messages.common.language} + +
+ + + + + + +
+
+
+ + + + + + + {messages.common.language} + + { + const nextLocale = value === "zh" ? "zh" : "en"; + switchLocale(nextLocale); + }} + > + English + 中文 + + + + +
+ +
+
+
+ ); +} diff --git a/src/components/dashboard/sidebar-site-details.tsx b/src/components/dashboard/sidebar-site-details.tsx index 10cb442e..c4ada45a 100644 --- a/src/components/dashboard/sidebar-site-details.tsx +++ b/src/components/dashboard/sidebar-site-details.tsx @@ -65,6 +65,7 @@ interface SidebarSiteDetailsProps { teamId: string; teamSlug: string; activeSiteSlug?: string; + currentSection?: string; sites: SidebarSiteSummary[]; labels: { views: string; @@ -88,8 +89,11 @@ function buildSitePath( locale: Locale, teamSlug: string, siteSlug: string, + section?: string, ): string { - return `/${locale}/app/${teamSlug}/${siteSlug}`; + const base = `/${locale}/app/${teamSlug}/${siteSlug}`; + if (!section) return base; + return `${base}/${section}`; } function safeCount(value: number): number { @@ -201,6 +205,7 @@ export function SidebarSiteDetails({ teamId, teamSlug, activeSiteSlug, + currentSection, sites, labels, messages, @@ -408,7 +413,14 @@ export function SidebarSiteDetails({ tooltip={site.name} className="h-8 rounded-none" > - + diff --git a/src/components/dashboard/site-pages/overview-client-page.tsx b/src/components/dashboard/site-pages/overview-client-page.tsx index 62738daa..ff98f367 100644 --- a/src/components/dashboard/site-pages/overview-client-page.tsx +++ b/src/components/dashboard/site-pages/overview-client-page.tsx @@ -282,14 +282,23 @@ function normalizeTrendData( } function metricCellBorderClasses(index: number): string { + // Mobile (1-col): top border for all except first const mobileHasTop = index >= 1; - const wideHasLeft = index % 3 !== 0; - const wideHasTop = index >= 3; + // md (2-col): left border on odd indices, top border for row 2+ + const mdHasLeft = index % 2 !== 0; + const mdHasTop = index >= 2; + // lg (3-col): left border on col 2/3, top border for row 2+ + const lgHasLeft = index % 3 !== 0; + const lgHasTop = index >= 3; return cn( mobileHasTop ? "border-t" : "", - wideHasLeft ? "sm:border-l" : "sm:border-l-0", - wideHasTop ? "sm:border-t" : "sm:border-t-0", + // md (2-col): reset mobile top for row 2+, apply left + top + mdHasTop ? "md:border-t" : "md:border-t-0", + mdHasLeft ? "md:border-l" : "md:border-l-0", + // lg (3-col): override md borders + lgHasTop ? "lg:border-t" : "lg:border-t-0", + lgHasLeft ? "lg:border-l" : "lg:border-l-0", ); } @@ -4499,7 +4508,7 @@ export function OverviewMetricsSection({ return ( -
+
{metrics.map((item, index) => { const hasDelta = typeof item.delta === "number" && Number.isFinite(item.delta); diff --git a/src/components/dashboard/site-pages/pages-client-page.tsx b/src/components/dashboard/site-pages/pages-client-page.tsx index f0e1ab1f..d4b6d96e 100644 --- a/src/components/dashboard/site-pages/pages-client-page.tsx +++ b/src/components/dashboard/site-pages/pages-client-page.tsx @@ -271,7 +271,7 @@ export function PagesClientPage({ const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [appendError, setAppendError] = useState(null); - const sentinelRef = useRef(null); + const [sentinelNode, setSentinelNode] = useState(null); const latestRequestKeyRef = useRef(""); const filtersKey = useMemo(() => JSON.stringify(filters ?? {}), [filters]); const requestKey = useMemo( @@ -358,12 +358,13 @@ export function PagesClientPage({ }, [requestKey]); useEffect(() => { - const target = sentinelRef.current; + const target = sentinelNode; if ( !target || loadingInitial || loadingMore || appendError !== null || + error !== null || !meta.hasMore || typeof IntersectionObserver === "undefined" ) { @@ -385,10 +386,26 @@ export function PagesClientPage({ ); observer.observe(target); + const frameId = globalThis.requestAnimationFrame(() => { + const rect = target.getBoundingClientRect(); + if (rect.top <= globalThis.innerHeight + 480 && rect.bottom >= -480) { + loadNextPage(); + } + }); + return () => { + globalThis.cancelAnimationFrame(frameId); observer.disconnect(); }; - }, [appendError, loadingInitial, loadingMore, meta.hasMore]); + }, [ + appendError, + error, + loadingInitial, + loadingMore, + meta.hasMore, + meta.nextPage, + sentinelNode, + ]); const shouldShowLoadMoreSkeletons = !loadingInitial && !error && items.length > 0 && meta.hasMore; @@ -457,7 +474,7 @@ export function PagesClientPage({ ? Array.from({ length: 2 }, (_, index) => (
diff --git a/src/components/dashboard/site-pages/settings-client-page.tsx b/src/components/dashboard/site-pages/settings-client-page.tsx index 627a0ada..00c4553e 100644 --- a/src/components/dashboard/site-pages/settings-client-page.tsx +++ b/src/components/dashboard/site-pages/settings-client-page.tsx @@ -69,7 +69,10 @@ interface SiteSettingsClientPageProps { slug: string; name: string; }>; - site: Pick; + site: Pick< + SiteData, + "id" | "name" | "domain" | "publicEnabled" | "publicSlug" + >; } interface ActionResponse { @@ -113,6 +116,15 @@ function resolveSiteSlug( return site.id.slice(0, 8); } +function randomPublicSlug(): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + const values = new Uint8Array(8); + crypto.getRandomValues(values); + return Array.from(values, (value) => alphabet[value % alphabet.length]).join( + "", + ); +} + function formatSampleRateValue(value: number): string { const formatted = Number.isInteger(value) ? String(value) @@ -165,13 +177,20 @@ export function SettingsClientPage({ const copy = messages.siteSettings; const [name, setName] = useState(site.name); const [domain, setDomain] = useState(site.domain); + const [publicEnabled, setPublicEnabled] = useState( + Boolean(site.publicEnabled), + ); const [publicSlug, setPublicSlug] = useState(site.publicSlug || ""); const [persistedName, setPersistedName] = useState(site.name); const [persistedDomain, setPersistedDomain] = useState(site.domain); + const [persistedPublicEnabled, setPersistedPublicEnabled] = useState( + Boolean(site.publicEnabled), + ); const [persistedPublicSlug, setPersistedPublicSlug] = useState( site.publicSlug || "", ); const [saving, setSaving] = useState(false); + const [savingPublicSharing, setSavingPublicSharing] = useState(false); const [savingTrackingStrength, setSavingTrackingStrength] = useState(false); const [savingQueryHash, setSavingQueryHash] = useState(false); const [savingPerformanceTracking, setSavingPerformanceTracking] = @@ -214,6 +233,7 @@ export function SettingsClientPage({ const [persistedSettings, setPersistedSettings] = useState( DEFAULT_SITE_SCRIPT_SETTINGS, ); + const [origin, setOrigin] = useState(""); const hasAutoTrackingChanges = autoTrackOutboundLinks !== persistedSettings.autoTrackOutboundLinks; @@ -236,7 +256,10 @@ export function SettingsClientPage({ const hasSiteInfoChanges = name.trim() !== persistedName.trim() || - domain.trim() !== persistedDomain.trim() || + domain.trim() !== persistedDomain.trim(); + + const hasPublicSharingChanges = + publicEnabled !== persistedPublicEnabled || publicSlug.trim() !== persistedPublicSlug.trim(); const hasTrackingStrengthChanges = @@ -277,6 +300,10 @@ export function SettingsClientPage({ setPathBlacklistInput(formatListInput(normalized.pathBlacklist)); } + useEffect(() => { + setOrigin(window.location.origin); + }, []); + useEffect(() => { let active = true; setLoadingSettings(true); @@ -402,15 +429,12 @@ export function SettingsClientPage({ siteId: site.id, name: name.trim(), domain: domain.trim(), - publicSlug: publicSlug.trim() || undefined, }); setName(updated.name); setDomain(updated.domain); - setPublicSlug(updated.publicSlug || ""); setPersistedName(updated.name); setPersistedDomain(updated.domain); - setPersistedPublicSlug(updated.publicSlug || ""); toast.success(copy.toasts.saved); const nextSlug = resolveSiteSlug(updated); @@ -432,6 +456,48 @@ export function SettingsClientPage({ } } + async function handleSavePublicSharing() { + if (!hasPublicSharingChanges) return; + + setSavingPublicSharing(true); + try { + const nextPublicSlug = publicEnabled + ? publicSlug.trim() || randomPublicSlug() + : publicSlug.trim(); + const updated = await postJson("/api/admin/site", { + intent: "update", + siteId: site.id, + publicEnabled, + publicSlug: nextPublicSlug || undefined, + }); + + const updatedPublicEnabled = Boolean(updated.publicEnabled); + const updatedPublicSlug = updated.publicSlug || ""; + setPublicEnabled(updatedPublicEnabled); + setPublicSlug(updatedPublicSlug); + setPersistedPublicEnabled(updatedPublicEnabled); + setPersistedPublicSlug(updatedPublicSlug); + toast.success(copy.toasts.saved); + + const nextSlug = resolveSiteSlug(updated); + if (nextSlug !== currentSiteSlug) { + setCurrentSiteSlug(nextSlug); + navigateWithTransition( + router, + `/${locale}/app/${teamSlug}/${nextSlug}/settings`, + ); + } else { + router.refresh(); + } + } catch (error) { + const message = + error instanceof Error ? error.message : copy.toasts.saveFailed; + toast.error(message || copy.toasts.saveFailed); + } finally { + setSavingPublicSharing(false); + } + } + async function persistTrackingSettings(input: Record) { const normalizedSettings = normalizeSiteScriptSettings({ ...persistedSettings, @@ -613,6 +679,22 @@ export function SettingsClientPage({ } } + async function handleCopyPublicLink() { + const link = publicLink; + if (!link) return; + try { + await navigator.clipboard.writeText(link); + toast.success(copy.copiedLink); + } catch { + toast.error(copy.toasts.saveFailed); + } + } + + const publicLink = + publicEnabled && publicSlug.trim() && origin + ? `${origin}/${locale}/share/${encodeURIComponent(publicSlug.trim())}` + : ""; + return (
@@ -667,24 +749,6 @@ export function SettingsClientPage({ />
-
- - setPublicSlug(event.target.value)} - disabled={ - saving || - trackingSaving || - transferring || - deleting || - loadingSettings - } - /> -
- +
+

+ {publicEnabled ? copy.publicLinkHint : copy.publicDisabledHint} +

+
+ + + + + {copy.scriptTitle} diff --git a/src/components/dashboard/system-performance-client.tsx b/src/components/dashboard/system-performance-client.tsx index 3696c40e..1a9d2eca 100644 --- a/src/components/dashboard/system-performance-client.tsx +++ b/src/components/dashboard/system-performance-client.tsx @@ -216,15 +216,24 @@ function SystemMetricCell({ {label}

-

- {value} -

+

+ {value} +

+

{detail}

@@ -649,15 +658,24 @@ function DoDiagnosticCell({

{label}

-

- {value} -

+

+ {value} +

+ {detail ? (

{detail} @@ -679,14 +697,23 @@ function DoDiagnosticKv({ return (

{label} - - {value} - + + {value} + +
); } @@ -1069,29 +1096,62 @@ export function SystemPerformanceClient({
{t.open}
-
- {openVisits - ? formatMetricNumber(locale, openVisits.total) - : "--"} -
+ +
+ {openVisits + ? formatMetricNumber(locale, openVisits.total) + : "--"} +
+
{t.stale}
-
- {openVisits - ? formatMetricNumber(locale, openVisits.stale) - : "--"} -
+ +
+ {openVisits + ? formatMetricNumber(locale, openVisits.stale) + : "--"} +
+
{t.timedOut}
-
- {openVisits - ? formatMetricNumber(locale, openVisits.timedOut) - : "--"} -
+ +
+ {openVisits + ? formatMetricNumber(locale, openVisits.timedOut) + : "--"} +
+
@@ -1139,19 +1199,46 @@ export function SystemPerformanceClient({
{t.trustedSamples}
-
- {summary - ? formatMetricNumber(locale, summary.trustedLatencySamples) - : "--"} -
+ +
+ {summary + ? formatMetricNumber( + locale, + summary.trustedLatencySamples, + ) + : "--"} +
+
{t.avgLatency}
-
- {summary ? formatLatency(locale, summary.avgLatencyMs) : "--"} -
+ +
+ {summary + ? formatLatency(locale, summary.avgLatencyMs) + : "--"} +
+
diff --git a/src/i18n/en.yaml b/src/i18n/en.yaml index 71efaa18..0d8b76f5 100644 --- a/src/i18n/en.yaml +++ b/src/i18n/en.yaml @@ -668,14 +668,26 @@ performance: pathsAnalyzedLabel: Paths analyzed metricValueColumn: P75 value statusColumn: Status +share: + title: "{siteName} - Analytics" + poweredBy: powered by siteSettings: title: Site Settings subtitle: Configure this site's basic information and lifecycle. editTitle: Update Site Info - editSubtitle: Keep display name, domain and public slug up to date. + editSubtitle: Keep display name and domain up to date. nameLabel: Site Name domainLabel: Domain - publicSlugLabel: Public Slug (optional) + publicSharingTitle: Public Sharing + publicSharingSubtitle: Configure this site's public access link. When enabled, anyone with the link can view analytics data. + publicEnabledLabel: Enable Public Access + publicSlugLabel: Public Slug + publicSlugPlaceholder: e.g. my-site + publicSlugHint: Customize the URL path identifier. Leave blank to generate one. + publicLinkLabel: Public Link + publicLinkHint: Share analytics data with this link after public access is enabled. + publicDisabledHint: The sharing link appears after public access is enabled. + copiedLink: Link copied trackingStrengthGroupTitle: Tracking Strength trackingStrengthDescription: Choose how aggressively the tracker identifies visitors. trackingStrengthLabel: Tracking Strength Mode @@ -875,6 +887,20 @@ teamManagement: title: Public Links subtitle: Manage publicly accessible sharing links for this team. empty: No public link exists for this team. + enabled: Enabled + disabled: Disabled + disabledHint: Public access is disabled. Open site settings to enable it. + viewSettings: View Settings + publicUrl: Public Link + copyLink: Copy Link + linkCopied: Link copied + noSites: This team has no sites yet. + columns: + site: Site + domain: Domain + publicUrl: Public Link + status: Status + action: Action apiKeys: title: API Keys subtitle: Manage API access keys for this team. diff --git a/src/i18n/zh.yaml b/src/i18n/zh.yaml index dce83581..bc77953f 100644 --- a/src/i18n/zh.yaml +++ b/src/i18n/zh.yaml @@ -646,14 +646,26 @@ performance: pathsAnalyzedLabel: 已分析路径 metricValueColumn: P75 数值 statusColumn: 状态 +share: + title: "{siteName} - 分析数据" + poweredBy: powered by siteSettings: title: 站点设置 subtitle: 管理当前站点的基础信息与生命周期。 editTitle: 修改站点信息 - editSubtitle: 更新站点名称、域名和公开 Slug。 + editSubtitle: 更新站点名称和域名。 nameLabel: 站点名称 domainLabel: 域名 - publicSlugLabel: 公开 Slug(可选) + publicSharingTitle: 公开分享 + publicSharingSubtitle: 配置站点的公开访问链接,启用后任何人可通过链接查看分析数据。 + publicEnabledLabel: 启用公开访问 + publicSlugLabel: 公开 Slug + publicSlugPlaceholder: 例如 my-site + publicSlugHint: 自定义 URL 路径标识。留空将自动生成。 + publicLinkLabel: 公开链接 + publicLinkHint: 启用公开访问后,可通过此链接分享分析数据。 + publicDisabledHint: 启用公开访问后将显示分享链接。 + copiedLink: 链接已复制 trackingStrengthGroupTitle: 跟踪强度 trackingStrengthDescription: 选择脚本对访客标识与统计精度的处理方式。 trackingStrengthLabel: 跟踪强度策略 @@ -844,6 +856,20 @@ teamManagement: title: 公开链接 subtitle: 管理当前团队可公开访问的分享链接。 empty: 当前团队还没有公开链接。 + enabled: 已启用 + disabled: 未启用 + disabledHint: 未启用公开访问。前往站点设置开启。 + viewSettings: 查看设置 + publicUrl: 公开链接 + copyLink: 复制链接 + linkCopied: 链接已复制 + noSites: 当前团队还没有站点。 + columns: + site: 站点 + domain: 域名 + publicUrl: 公开链接 + status: 状态 + action: 操作 apiKeys: title: API 密钥 subtitle: 管理当前团队用于接口访问的密钥。 diff --git a/src/lib/dashboard/__tests__/client-data.test.ts b/src/lib/dashboard/__tests__/client-data.test.ts index d4d67c9e..823efb44 100644 --- a/src/lib/dashboard/__tests__/client-data.test.ts +++ b/src/lib/dashboard/__tests__/client-data.test.ts @@ -1190,6 +1190,7 @@ describe("Dashboard Client Data Processing Utilities", () => { region: "", regionCode: "CA", city: "", + pointCount: 1, }); expect(mapped.countryCounts[0]).toEqual({ country: "", diff --git a/src/lib/dashboard/__tests__/client-geo-data.test.ts b/src/lib/dashboard/__tests__/client-geo-data.test.ts index 23176cc6..ab577a3f 100644 --- a/src/lib/dashboard/__tests__/client-geo-data.test.ts +++ b/src/lib/dashboard/__tests__/client-geo-data.test.ts @@ -91,6 +91,7 @@ describe("dashboard client geo data helpers", () => { region: "", regionCode: "IDF", city: "Paris", + pointCount: 1, }, ], countryCounts: [{ country: "", views: 8, sessions: 4, visitors: 0 }], diff --git a/src/lib/dashboard/__tests__/client-request.test.ts b/src/lib/dashboard/__tests__/client-request.test.ts index 9f0c3367..87cb9313 100644 --- a/src/lib/dashboard/__tests__/client-request.test.ts +++ b/src/lib/dashboard/__tests__/client-request.test.ts @@ -3,7 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchPrivateJson, fetchPrivateJsonMutate, + publicDashboardSiteId, } from "@/lib/dashboard/client-request"; +import { handleDemoRequest } from "@/lib/realtime/mock"; + +vi.mock("@/lib/realtime/mock", () => ({ + handleDemoRequest: vi.fn(), +})); describe("dashboard client request helpers", () => { const realFetch = globalThis.fetch; @@ -17,6 +23,7 @@ describe("dashboard client request helpers", () => { process.env.NEXT_PUBLIC_DEMO_MODE = realDemoMode; } vi.restoreAllMocks(); + vi.mocked(handleDemoRequest).mockReset(); }); function jsonResponse(body: unknown, status = 200): Response { @@ -103,6 +110,62 @@ describe("dashboard client request helpers", () => { }); }); + it("rewrites public dashboard GET requests and omits credentials", async () => { + delete process.env.NEXT_PUBLIC_DEMO_MODE; + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(jsonResponse({ ok: true, value: "public" })), + ); + globalThis.fetch = fetchMock; + + await expect( + fetchPrivateJson("/api/private/overview", { + siteId: publicDashboardSiteId("team site/one"), + from: 1, + to: 2, + }), + ).resolves.toEqual({ ok: true, value: "public" }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/public/team%20site%2Fone/overview?from=1&to=2", + expect.objectContaining({ + method: "GET", + credentials: "omit", + }), + ); + expect(String(fetchMock.mock.calls[0][0])).not.toContain("siteId="); + }); + + it("keeps public and private request dedupe keys separate", async () => { + delete process.env.NEXT_PUBLIC_DEMO_MODE; + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(jsonResponse({ ok: true, value: "fresh" })), + ); + globalThis.fetch = fetchMock; + + await Promise.all([ + fetchPrivateJson("/api/private/overview", { siteId: "site-1" }), + fetchPrivateJson("/api/private/overview", { + siteId: publicDashboardSiteId("site-1"), + }), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/private/overview?siteId=site-1", + ); + expect(fetchMock.mock.calls[1][0]).toBe("/api/public/site-1/overview"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + credentials: "include", + }); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + credentials: "omit", + }); + }); + it("throws AbortError before issuing a request when the signal is already aborted", async () => { delete process.env.NEXT_PUBLIC_DEMO_MODE; const fetchMock = vi.fn(); @@ -178,12 +241,19 @@ describe("dashboard client request helpers", () => { process.env.NEXT_PUBLIC_DEMO_MODE = "1"; const fetchMock = vi.fn(); globalThis.fetch = fetchMock; + vi.mocked(handleDemoRequest).mockReturnValue({ ok: true }); await expect( fetchPrivateJsonMutate("/api/private/auth/login", "POST", undefined, { username: "demo", }), ).resolves.toMatchObject({ ok: true }); + expect(handleDemoRequest).toHaveBeenCalledWith({ + path: "/api/private/auth/login", + method: "POST", + params: undefined, + body: { username: "demo" }, + }); expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/dashboard/__tests__/query-state.test.ts b/src/lib/dashboard/__tests__/query-state.test.ts index 8814d075..aa6edfbe 100644 --- a/src/lib/dashboard/__tests__/query-state.test.ts +++ b/src/lib/dashboard/__tests__/query-state.test.ts @@ -51,6 +51,7 @@ describe("dashboard query-state helpers", () => { expect(finestIntervalForRange(0, 45 * MINUTE_MS)).toBe("minute"); expect(finestIntervalForRange(0, 2 * HOUR_MS)).toBe("hour"); expect(finestIntervalForRange(0, 30 * DAY_MS)).toBe("day"); + expect(finestIntervalForRange(0, 90 * DAY_MS + 12 * HOUR_MS)).toBe("day"); expect(finestIntervalForRange(0, 91 * DAY_MS)).toBe("month"); expect(clampIntervalForRange(undefined, 0, 45 * MINUTE_MS)).toBe( @@ -137,7 +138,7 @@ describe("dashboard query-state helpers", () => { expect(resolveTimeWindow("90d", now, { timeZone: "UTC" })).toMatchObject({ from: Date.UTC(2026, 4, 26) - 90 * DAY_MS, to: now, - interval: "month", + interval: "day", }); expect(resolveTimeWindow("6m", now, { timeZone: "UTC" })).toMatchObject({ from: Date.UTC(2025, 10, 1), diff --git a/src/lib/dashboard/client-geo-data.ts b/src/lib/dashboard/client-geo-data.ts index 1dc182d4..f83b7fe1 100644 --- a/src/lib/dashboard/client-geo-data.ts +++ b/src/lib/dashboard/client-geo-data.ts @@ -70,6 +70,10 @@ export async function fetchOverviewGeoPoints( (row as { regionCode?: unknown }).regionCode ?? "", ), city: String((row as { city?: unknown }).city ?? ""), + pointCount: Math.max( + 1, + Number((row as { pointCount?: unknown }).pointCount ?? 1), + ), })) : [], countryCounts: Array.isArray(payload.countryCounts) diff --git a/src/lib/dashboard/client-request.ts b/src/lib/dashboard/client-request.ts index 00b0e9d3..0a27b1eb 100644 --- a/src/lib/dashboard/client-request.ts +++ b/src/lib/dashboard/client-request.ts @@ -10,6 +10,7 @@ import { toQueryString } from "./client-utils"; // fetches for the same URL. The map is cleared as soon as the request // settles so subsequent retries / re-fetches still hit the network. const inflightPrivateRequests = new Map>(); +const PUBLIC_SITE_ID_PREFIX = "public:"; function throwAbortError(): never { const error = new Error("Aborted"); @@ -17,6 +18,32 @@ function throwAbortError(): never { throw error; } +function publicSlugFromParams(params?: PrivateRequestParams): string | null { + const siteId = params?.siteId; + if (typeof siteId !== "string") return null; + if (!siteId.startsWith(PUBLIC_SITE_ID_PREFIX)) return null; + const slug = siteId.slice(PUBLIC_SITE_ID_PREFIX.length).trim(); + return slug.length > 0 ? slug : null; +} + +function publicPathForPrivateRequest(path: string, slug: string): string { + const endpoint = path.replace(/^\/api\/private\/?/, ""); + return `/api/public/${encodeURIComponent(slug)}/${endpoint}`; +} + +function paramsWithoutSiteId( + params?: PrivateRequestParams, +): PrivateRequestParams | undefined { + if (!params) return undefined; + const next = { ...params }; + delete next.siteId; + return next; +} + +export function publicDashboardSiteId(slug: string): string { + return `${PUBLIC_SITE_ID_PREFIX}${slug}`; +} + export async function fetchPrivateJson( path: string, params?: PrivateRequestParams, @@ -25,14 +52,19 @@ export async function fetchPrivateJson( if (options?.signal?.aborted) { throwAbortError(); } + const publicSlug = publicSlugFromParams(params); + const requestPath = publicSlug + ? publicPathForPrivateRequest(path, publicSlug) + : path; + const requestParams = publicSlug ? paramsWithoutSiteId(params) : params; if (process.env.NEXT_PUBLIC_DEMO_MODE === "1") { const { handleDemoRequest } = await import("@/lib/realtime/mock"); if (options?.signal?.aborted) { throwAbortError(); } - return handleDemoRequest({ path, params }) as T; + return handleDemoRequest({ path: requestPath, params: requestParams }) as T; } - const url = `${path}${toQueryString(params)}`; + const url = `${requestPath}${toQueryString(requestParams)}`; const shouldDedupe = options?.dedupe !== false && !options?.signal; const existing = shouldDedupe ? (inflightPrivateRequests.get(url) as Promise | undefined) @@ -41,12 +73,12 @@ export async function fetchPrivateJson( const promise = (async () => { const res = await fetch(url, { method: "GET", - credentials: "include", + credentials: publicSlug ? "omit" : "include", signal: options?.signal, }); if (!res.ok) { const text = await res.text(); - throw new Error(`Request failed (${res.status} ${path}): ${text}`); + throw new Error(`Request failed (${res.status} ${requestPath}): ${text}`); } return (await res.json()) as T; })(); diff --git a/src/lib/dashboard/query-state.ts b/src/lib/dashboard/query-state.ts index 23a0c296..5f8cfff2 100644 --- a/src/lib/dashboard/query-state.ts +++ b/src/lib/dashboard/query-state.ts @@ -109,7 +109,6 @@ const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; const YEAR_MS = 366 * DAY_MS; -const NINETY_DAYS_MS = 90 * DAY_MS; function normalizeFilterValue( value: string | null | undefined, @@ -298,10 +297,10 @@ export function allowedIntervalsForRange( ): DashboardInterval[] { const span = spanMs(from, to); const allowed = INTERVAL_ORDER.filter((interval) => { - if (interval === "minute") return span <= HOUR_MS; - if (interval === "hour") return span <= 7 * DAY_MS; - if (interval === "day") return span <= 90 * DAY_MS; - if (interval === "week") return span <= YEAR_MS; + if (interval === "minute") return span < HOUR_MS + MINUTE_MS; + if (interval === "hour") return span < 8 * DAY_MS; + if (interval === "day") return span < 91 * DAY_MS; + if (interval === "week") return span < YEAR_MS + DAY_MS; return true; }); @@ -315,7 +314,7 @@ export function finestIntervalForRange( const span = spanMs(from, to); if (span <= HOUR_MS) return "minute"; if (span <= DAY_MS) return "hour"; - if (span <= NINETY_DAYS_MS) return "day"; + if (span < 91 * DAY_MS) return "day"; return "month"; } diff --git a/src/lib/edge-client-types/overview.ts b/src/lib/edge-client-types/overview.ts index 8a6e757d..e274e5de 100644 --- a/src/lib/edge-client-types/overview.ts +++ b/src/lib/edge-client-types/overview.ts @@ -94,6 +94,7 @@ export interface OverviewGeoPointsData { region?: string; regionCode?: string; city?: string; + pointCount?: number; }>; countryCounts: Array<{ country: string; diff --git a/src/lib/edge-client.ts b/src/lib/edge-client.ts index 97d1a0ba..40a835cc 100644 --- a/src/lib/edge-client.ts +++ b/src/lib/edge-client.ts @@ -21,6 +21,13 @@ export type * from "@/lib/edge-client-types"; type HttpMethod = "GET" | "POST" | "PATCH"; +export interface PublicSiteData { + id: string; + slug: string; + name: string; + domain: string; +} + interface FetchEdgeOptions { method?: HttpMethod; path: string; @@ -163,6 +170,17 @@ export async function fetchPublicOverview( }); } +export async function fetchPublicSite(slug: string): Promise { + const res = await fetchEdgeJson<{ ok: boolean; data: PublicSiteData }>({ + path: `/api/public/${encodeURIComponent(slug)}/site`, + isPublic: true, + }); + if (!res.ok || !res.data) { + throw new Error("Public site not found"); + } + return res.data; +} + export async function fetchPublicTrend( slug: string, params: { diff --git a/src/lib/edge/__tests__/admin-ws.test.ts b/src/lib/edge/__tests__/admin-ws.test.ts new file mode 100644 index 00000000..512ed348 --- /dev/null +++ b/src/lib/edge/__tests__/admin-ws.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; + +import { handleAdminWs } from "@/lib/edge/admin-ws"; + +function bytes(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +function toArrayBuffer(input: Uint8Array): ArrayBuffer { + const out = new Uint8Array(input.length); + out.set(input); + return out.buffer; +} + +function base64UrlEncode(input: Uint8Array): string { + let binary = ""; + for (let i = 0; i < input.length; i += 1) { + binary += String.fromCharCode(input[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +async function hmacSha256( + message: string, + secret: string, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + toArrayBuffer(bytes(secret)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + toArrayBuffer(bytes(message)), + ); + return new Uint8Array(sig); +} + +async function sessionToken( + claims: Record, + secret: string, +): Promise { + const payload = base64UrlEncode(bytes(JSON.stringify(claims))); + const signature = await hmacSha256(payload, secret); + return `${payload}.${base64UrlEncode(signature)}`; +} + +function dbWithRows(rows: Record[]) { + return { + prepare: vi.fn(() => ({ + bind: vi.fn(() => ({ + first: vi.fn(async () => rows.shift() ?? null), + })), + })), + }; +} + +describe("handleAdminWs", () => { + it("rejects requests when session secrets or tokens are invalid", async () => { + const unavailable = await handleAdminWs( + new Request("https://app.test/admin/ws?siteId=site-1"), + { DB: dbWithRows([]), INGEST_DO: {} } as any, + ); + expect(unavailable.status).toBe(503); + + const unauthorized = await handleAdminWs( + new Request("https://app.test/admin/ws?siteId=site-1", { + headers: { authorization: "Bearer invalid" }, + }), + { MAIN_SECRET: "root", DB: dbWithRows([]), INGEST_DO: {} } as any, + ); + expect(unauthorized.status).toBe(401); + }); + + it("checks site access and forwards websocket requests to the ingest DO", async () => { + const secret = "dashboard-secret"; + const token = await sessionToken( + { + userId: "user-1", + username: "admin", + systemRole: "admin", + exp: Math.floor(Date.now() / 1000) + 60, + }, + secret, + ); + const fetchMock = vi.fn(async (_request: Request) => + Promise.resolve(new Response("upgraded")), + ); + const env = { + DASHBOARD_SESSION_SECRET: secret, + DB: dbWithRows([{ id: "site-1" }]), + INGEST_DO: { + idFromName: vi.fn(() => "do-id"), + get: vi.fn(() => ({ fetch: fetchMock })), + }, + }; + + const response = await handleAdminWs( + new Request("https://app.test/admin/ws?siteId=site-1&token=client", { + headers: { authorization: `Bearer ${token}` }, + }), + env as any, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("upgraded"); + expect(env.INGEST_DO.idFromName).toHaveBeenCalledWith("site-1"); + expect(fetchMock).toHaveBeenCalledWith(expect.any(Request)); + const forwarded = fetchMock.mock.calls[0]?.[0] as Request; + expect(forwarded.url).toBe( + "https://ingest.internal/ws?siteId=site-1&token=client", + ); + }); + + it("rejects missing and unauthorized site ids", async () => { + const secret = "dashboard-secret"; + const token = await sessionToken( + { + userId: "user-1", + username: "user", + systemRole: "user", + exp: Math.floor(Date.now() / 1000) + 60, + }, + secret, + ); + const headers = { authorization: `Bearer ${token}` }; + + const missing = await handleAdminWs( + new Request("https://app.test/admin/ws", { headers }), + { + DASHBOARD_SESSION_SECRET: secret, + DB: dbWithRows([]), + INGEST_DO: {}, + } as any, + ); + expect(missing.status).toBe(400); + + const forbidden = await handleAdminWs( + new Request("https://app.test/admin/ws?siteId=site-1", { headers }), + { + DASHBOARD_SESSION_SECRET: secret, + DB: dbWithRows([null as any]), + INGEST_DO: {}, + } as any, + ); + expect(forbidden.status).toBe(403); + }); +}); diff --git a/src/lib/edge/__tests__/api-v1-docs.test.ts b/src/lib/edge/__tests__/api-v1-docs.test.ts index 5773c160..345ac0d0 100644 --- a/src/lib/edge/__tests__/api-v1-docs.test.ts +++ b/src/lib/edge/__tests__/api-v1-docs.test.ts @@ -173,6 +173,25 @@ describe("api v1 public docs", () => { "#/components/schemas/EventSearchRequest", ); + expect( + spec.components.schemas.SiteCreateInput.properties?.sharing, + ).toBeUndefined(); + expect( + spec.components.schemas.SiteUpdateInput.properties?.sharing, + ).toBeUndefined(); + expect(spec.components.schemas.SiteCreateInput.properties).toEqual( + expect.objectContaining({ + publicEnabled: expect.objectContaining({ type: "boolean" }), + publicSlug: expect.objectContaining({ type: "string" }), + }), + ); + expect(spec.components.schemas.SiteUpdateInput.properties).toEqual( + expect.objectContaining({ + publicEnabled: expect.objectContaining({ type: "boolean" }), + publicSlug: expect.objectContaining({ type: "string" }), + }), + ); + walk(spec.paths, (value) => { if (!value || typeof value !== "object" || !("requestBody" in value)) { return; diff --git a/src/lib/edge/__tests__/api-v1.test.ts b/src/lib/edge/__tests__/api-v1.test.ts index 91e89eb5..38dbe02b 100644 --- a/src/lib/edge/__tests__/api-v1.test.ts +++ b/src/lib/edge/__tests__/api-v1.test.ts @@ -1088,6 +1088,23 @@ describe("api v1 gateway", () => { expect(body.data.publicSlug).toBe("my-blog"); }); + it("disables sharing and clears the public slug via PATCH", async () => { + const { response } = await authed( + "/api/v1/sites/site-1/sharing", + [siteMatch("site-1", "Blog")], + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ publicEnabled: false, publicSlug: "old-blog" }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + data: { publicEnabled: false, publicSlug: null }, + }); + }); + it("returns 409 when sharing slug conflicts", async () => { vi.mocked(ensurePublicSlugAvailable).mockResolvedValueOnce(false); const { response } = await authed( @@ -1306,6 +1323,127 @@ describe("api v1 gateway", () => { expect(response.status).toBe(403); }); + it("prevents restricted keys from reading and updating unauthorized sites", async () => { + const get = await authed( + "/api/v1/sites/site-1", + [siteMatch("site-1", "Blog")], + undefined, + { site_ids_json: JSON.stringify(["site-2"]) }, + ); + expect(get.response.status).toBe(404); + + const patch = await authed( + "/api/v1/sites/site-1", + [siteMatch("site-1", "Blog")], + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Blocked" }), + }, + { site_ids_json: JSON.stringify(["site-2"]) }, + ); + expect(patch.response.status).toBe(404); + }); + + it("prevents restricted keys from reading unauthorized analytics families", async () => { + const overrides = { site_ids_json: JSON.stringify(["site-2"]) }; + const cases = [ + "/api/v1/sites/site-1/analytics/overview?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + "/api/v1/sites/site-1/events?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + "/api/v1/sites/site-1/sessions?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + "/api/v1/sites/site-1/visitors?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + "/api/v1/sites/site-1/realtime/snapshot", + ]; + + for (const path of cases) { + const { response } = await authed( + path, + [siteMatch("site-1", "Blog")], + undefined, + overrides, + ); + expect(response.status, path).toBe(404); + } + }); + + it("does not let batch bypass site restrictions or missing scopes", async () => { + const restricted = await authed( + "/api/v1/batch", + [siteMatch("site-1", "Blog")], + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requests: [ + { + id: "overview", + method: "GET", + path: "/api/v1/sites/site-1/analytics/overview", + query: { + from: "2026-06-01T00:00:00Z", + to: "2026-06-02T00:00:00Z", + }, + }, + ], + }), + }, + { site_ids_json: JSON.stringify(["site-2"]) }, + ); + expect(restricted.response.status).toBe(200); + await expect(restricted.response.json()).resolves.toMatchObject({ + data: { responses: [{ id: "overview", status: 404 }] }, + meta: { partialFailure: true }, + }); + + const noAnalytics = await authed( + "/api/v1/batch", + [siteMatch("site-1", "Blog")], + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requests: [ + { + id: "overview", + method: "GET", + path: "/api/v1/sites/site-1/analytics/overview", + }, + ], + }), + }, + { scopes_json: JSON.stringify(["site:read"]) }, + ); + expect(noAnalytics.response.status).toBe(200); + await expect(noAnalytics.response.json()).resolves.toMatchObject({ + data: { responses: [{ id: "overview", status: 403 }] }, + meta: { partialFailure: true }, + }); + + const writeAttempt = await authed( + "/api/v1/batch", + [siteMatch("site-1", "Blog")], + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requests: [ + { + id: "sharing", + method: "PATCH", + path: "/api/v1/sites/site-1/sharing", + }, + ], + }), + }, + { scopes_json: JSON.stringify(["site_config:read"]) }, + ); + expect(writeAttempt.response.status).toBe(200); + await expect(writeAttempt.response.json()).resolves.toMatchObject({ + data: { responses: [{ id: "sharing", status: 400 }] }, + meta: { partialFailure: true }, + }); + }); + // ── additional coverage: analytics invalid interval ───────────── it("rejects invalid analytics timeseries interval", async () => { @@ -1359,6 +1497,14 @@ describe("api v1 gateway", () => { expect(response.status).toBe(400); }); + it("rejects cross-breakdown with unsupported session dimension", async () => { + const { response } = await authed( + "/api/v1/sites/site-1/analytics/cross-breakdowns?primary=session.entryPath&secondary=client.browser&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + [siteMatch("site-1", "Blog")], + ); + expect(response.status).toBe(400); + }); + // ── additional coverage: analytics compare ────────────────────── it("returns comparison analytics", async () => { @@ -1374,7 +1520,13 @@ describe("api v1 gateway", () => { it("returns explore analytics via POST", async () => { const { response } = await authed( "/api/v1/sites/site-1/analytics/explore?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", - [siteMatch("site-1", "Blog")], + [ + siteMatch("site-1", "Blog"), + { + includes: ["event_rollup", "GROUP BY scoped.d0"], + all: [{ d0: "/pricing", views: 5 }], + }, + ], { method: "POST", headers: { "content-type": "application/json" }, @@ -1391,6 +1543,9 @@ describe("api v1 gateway", () => { }; expect(body.data.metrics).toEqual(["views"]); expect(body.data.dimensions).toEqual(["page.path"]); + expect(body.data).toMatchObject({ + rows: [{ "page.path": "/pricing", views: 5 }], + }); }); it("rejects explore POST with invalid complex filters", async () => { @@ -1823,16 +1978,63 @@ describe("api v1 gateway", () => { // ── additional coverage: performance ──────────────────────────── it("returns performance data", async () => { - routeQueryMock.mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true, data: { ttfb: 100 } }), { - headers: { "content-type": "application/json" }, - }), + const { response } = await authed( + "/api/v1/sites/site-1/performance/summary?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", + [ + siteMatch("site-1", "Blog"), + { + includes: ["metric_thresholds", "thresholds.metric"], + all: [ + { + metric: "ttfb", + samples: 3, + avgValue: 110, + p50: 100, + p75: 120, + p95: 150, + }, + ], + }, + ], ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: { ttfb: 120, fcp: null, lcp: null, cls: null, inp: null }, + }); + }); + + it("returns performance breakdowns by documented dimension", async () => { const { response } = await authed( - "/api/v1/sites/site-1/performance?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", - [siteMatch("site-1", "Blog")], + "/api/v1/sites/site-1/performance/breakdowns/page.path?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z&metric=lcp", + [ + siteMatch("site-1", "Blog"), + { + includes: ["dimension_views", "thresholds.dimensionValue"], + all: [ + { + dimensionValue: "/pricing", + views: 7, + samples: 4, + avg: 1500, + p50: 1200, + p75: 1800, + p95: 2100, + }, + ], + }, + ], ); expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: [ + { + key: "/pricing", + label: "/pricing", + lcp: 1800, + samples: 4, + }, + ], + }); }); it("rejects non-GET on performance endpoint", async () => { @@ -1929,12 +2131,21 @@ describe("api v1 gateway", () => { // ── additional coverage: team sub-resources ───────────────────── it("returns team analytics breakdowns", async () => { - const matches = [teamSitesListMatch([{ id: "site-1", name: "One" }])]; + const matches = [ + sitesListMatch([{ id: "site-1", name: "One" }]), + { + includes: ["event_rollup"], + all: [{ d0: "US", views: 12, sessions: 8, visitors: 6 }], + }, + ]; const { response } = await authed( "/api/v1/team/analytics/breakdowns/geo.country?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", matches, ); expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + data: [{ key: "US", label: "US", views: 12, sessions: 8, visitors: 6 }], + }); }); it("returns 404 for unknown team analytics resource", async () => { diff --git a/src/lib/edge/__tests__/dashboard-cache.test.ts b/src/lib/edge/__tests__/dashboard-cache.test.ts index 88ed101b..6a920ed3 100644 --- a/src/lib/edge/__tests__/dashboard-cache.test.ts +++ b/src/lib/edge/__tests__/dashboard-cache.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { withDashboardCache } from "@/lib/edge/dashboard-cache"; +import { + PUBLIC_QUERY_CACHE_OPTIONS, + withDashboardCache, +} from "@/lib/edge/dashboard-cache"; describe("edge dashboard cache wrapper", () => { beforeEach(() => { @@ -24,6 +27,23 @@ describe("edge dashboard cache wrapper", () => { expect(generate).toHaveBeenCalledTimes(1); }); + it("adds public cache headers on bypass when requested", async () => { + const generate = vi.fn().mockResolvedValue(new Response("fresh")); + + const response = await withDashboardCache( + undefined, + new URL("https://example.test/api/public/site/overview"), + generate, + PUBLIC_QUERY_CACHE_OPTIONS, + ); + + expect(await response.text()).toBe("fresh"); + expect(response.headers.get("cache-control")).toBe( + "public, max-age=300, s-maxage=300", + ); + expect(response.headers.get("x-edge-cache")).toBeNull(); + }); + it("returns cached responses with HIT headers when a cache entry exists", async () => { const match = vi .fn() @@ -87,10 +107,11 @@ describe("edge dashboard cache wrapper", () => { }); it("does not cache non-OK responses and tolerates cache failures", async () => { + const put = vi.fn().mockResolvedValue(undefined); vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue({ match: vi.fn().mockRejectedValue(new Error("read failed")), - put: vi.fn().mockResolvedValue(undefined), + put, }), }); const generate = vi @@ -106,5 +127,6 @@ describe("edge dashboard cache wrapper", () => { expect(response.status).toBe(500); expect(await response.text()).toBe("nope"); expect(response.headers.get("x-edge-cache")).toBeNull(); + expect(put).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/edge/__tests__/legacy-hono-adapters.test.ts b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts new file mode 100644 index 00000000..a11ed3b8 --- /dev/null +++ b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts @@ -0,0 +1,397 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { handleAuthLoginAdmin } from "@/lib/edge/admin-users"; +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; +import { + handleLegacyAdminMember, + handleLegacyAdminProfile, + handleLegacyAdminSite, + handleLegacyAdminSiteConfig, + handleLegacyAdminTeam, + handleLegacyAdminUser, +} from "@/lib/edge/legacy-admin"; +import { + handleLegacyArchiveFile, + handleLegacyArchiveManifest, +} from "@/lib/edge/legacy-archive"; +import { + handleLegacyAuthLogin, + handleLegacyAuthLogout, +} from "@/lib/edge/legacy-auth"; + +vi.mock("@/lib/edge/admin", () => ({ + handlePrivateAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/archive-query", () => ({ + handlePrivateArchiveFile: vi.fn(), + handlePrivateArchiveManifest: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-users", () => ({ + handleAuthLoginAdmin: vi.fn(), +})); + +const env = { + MAIN_SECRET: "test-main-secret", +}; + +function jsonRequest(path: string, body: Record): Request { + return new Request(`https://app.test${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://app.test", + }, + body: JSON.stringify(body), + }); +} + +async function responseJson(response: Response) { + return (await response.json()) as Record; +} + +describe("legacy Hono edge adapters", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(handlePrivateAdmin).mockImplementation(async (request) => { + const body = await request.json().catch(() => ({})); + return Response.json({ + ok: true, + data: { + method: request.method, + pathname: new URL(request.url).pathname, + body, + }, + }); + }); + vi.mocked(handlePrivateArchiveManifest).mockResolvedValue( + Response.json({ + ok: true, + files: [{ archiveKey: "archive/site/hour.parquet" }], + }), + ); + vi.mocked(handlePrivateArchiveFile).mockResolvedValue( + new Response("parquet"), + ); + vi.mocked(handleAuthLoginAdmin).mockResolvedValue( + Response.json({ + ok: true, + data: { + user: { + id: "user-1", + username: "admin", + name: "Admin", + systemRole: "admin", + }, + teams: [], + }, + }), + ); + }); + + it("logs in through the private auth handler and sets the legacy cookie", async () => { + const response = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { + username: "admin", + password: "secret", + next: "/app/team", + }), + env as any, + ); + const body = await responseJson(response); + + expect(response.status).toBe(200); + expect(body.data).toEqual({ next: "/app/team" }); + expect(response.headers.get("set-cookie")).toContain("if_session="); + expect(handleAuthLoginAdmin).toHaveBeenCalledWith(expect.any(Request), env); + }); + + it("maps legacy auth validation, credential, and logout branches", async () => { + const invalid = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { username: "a", password: "" }), + env as any, + ); + expect(invalid.status).toBe(400); + + vi.mocked(handleAuthLoginAdmin).mockResolvedValueOnce( + Response.json({ ok: false }, { status: 401 }), + ); + const denied = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { + username: "admin", + password: "wrong", + next: "https://evil.test", + }), + env as any, + ); + expect(denied.status).toBe(401); + + const logout = handleLegacyAuthLogout( + new Request("https://app.test/api/auth/logout", { method: "POST" }), + ); + expect(logout.headers.get("set-cookie")).toContain("Max-Age=0"); + }); + + it("maps legacy auth upstream failures and malformed success payloads", async () => { + vi.mocked(handleAuthLoginAdmin).mockResolvedValueOnce( + new Response("service unavailable", { status: 503 }), + ); + const unavailable = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { + username: "admin", + password: "secret", + }), + env as any, + ); + expect(unavailable.status).toBe(503); + + vi.mocked(handleAuthLoginAdmin).mockResolvedValueOnce( + new Response("not json", { status: 200 }), + ); + const invalidJson = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { + username: "admin", + password: "secret", + }), + env as any, + ); + expect(invalidJson.status).toBe(502); + + vi.mocked(handleAuthLoginAdmin).mockResolvedValueOnce( + Response.json({ ok: true, data: {} }), + ); + const missingUser = await handleLegacyAuthLogin( + jsonRequest("/api/auth/login", { + username: "admin", + password: "secret", + }), + env as any, + ); + expect(missingUser.status).toBe(502); + }); + + it("adapts legacy admin user, team, site, member, profile, and config forms", async () => { + const calls = [ + handleLegacyAdminUser( + jsonRequest("/api/admin/user", { + username: "new-user", + email: "u@example.test", + password: "password123", + systemRole: "admin", + }), + env as any, + ), + handleLegacyAdminTeam( + jsonRequest("/api/admin/team", { name: "Team", slug: "team" }), + env as any, + ), + handleLegacyAdminSite( + jsonRequest("/api/admin/site", { + teamId: "team-1", + name: "Site", + domain: "example.test", + publicEnabled: "on", + }), + env as any, + ), + handleLegacyAdminMember( + jsonRequest("/api/admin/member", { + teamId: "team-1", + identifier: "u@example.test", + role: "admin", + }), + env as any, + ), + handleLegacyAdminProfile( + jsonRequest("/api/admin/profile", { + username: "admin", + email: "admin@example.test", + name: "", + timeZone: "UTC", + }), + env as any, + ), + handleLegacyAdminSiteConfig( + jsonRequest("/api/admin/site-config", { + siteId: "site-1", + maskQueryHashDetails: "false", + }), + env as any, + ), + ]; + + const responses = await Promise.all(calls); + expect(responses.map((response) => response.status)).toEqual([ + 200, 200, 200, 200, 200, 200, + ]); + expect(handlePrivateAdmin).toHaveBeenCalledTimes(6); + }); + + it("covers legacy admin mutation intents and validation failures", async () => { + expect( + ( + await handleLegacyAdminUser( + jsonRequest("/api/admin/user", { + intent: "update", + userId: "user-1", + username: "updated", + }), + env as any, + ) + ).status, + ).toBe(200); + expect( + ( + await handleLegacyAdminUser( + jsonRequest("/api/admin/user", { + intent: "delete", + userId: "user-1", + }), + env as any, + ) + ).status, + ).toBe(200); + expect( + ( + await handleLegacyAdminTeam( + jsonRequest("/api/admin/team", { + intent: "transfer_owner", + teamId: "team-1", + newOwnerUserId: "user-2", + }), + env as any, + ) + ).status, + ).toBe(200); + expect( + ( + await handleLegacyAdminSite( + jsonRequest("/api/admin/site", { + intent: "update", + siteId: "site-1", + name: "Updated", + }), + env as any, + ) + ).status, + ).toBe(200); + expect( + ( + await handleLegacyAdminSite( + jsonRequest("/api/admin/site", { intent: "remove", siteId: "" }), + env as any, + ) + ).status, + ).toBe(400); + expect( + ( + await handleLegacyAdminMember( + jsonRequest("/api/admin/member", { + intent: "update_role", + teamId: "team-1", + userId: "user-1", + role: "owner", + }), + env as any, + ) + ).status, + ).toBe(400); + }); + + it("rewrites legacy archive manifest URLs and streams file responses", async () => { + const manifest = await handleLegacyArchiveManifest( + new Request("https://app.test/api/archive/manifest?siteId=site-1", { + headers: { authorization: "Bearer token" }, + }), + env as any, + ); + const manifestBody = await responseJson(manifest); + expect( + (manifestBody.files as Array<{ fetchUrl: string }>)[0].fetchUrl, + ).toBe("/api/archive/file?key=archive%2Fsite%2Fhour.parquet"); + + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( + new Response("parquet", { + status: 206, + headers: { + "content-type": "application/vnd.apache.parquet", + "content-range": "bytes 0-6/7", + "content-length": "7", + etag: '"abc"', + }, + }), + ); + const file = await handleLegacyArchiveFile( + new Request("https://app.test/api/archive/file?key=archive-key", { + headers: { range: "bytes=0-6" }, + }), + env as any, + ); + expect(file.status).toBe(206); + expect(file.headers.get("content-range")).toBe("bytes 0-6/7"); + expect(await file.text()).toBe("parquet"); + }); + + it("covers legacy archive error and HEAD branches", async () => { + const missingManifestSite = await handleLegacyArchiveManifest( + new Request("https://app.test/api/archive/manifest"), + env as any, + ); + expect(missingManifestSite.status).toBe(400); + + vi.mocked(handlePrivateArchiveManifest).mockResolvedValueOnce( + new Response("nope", { status: 403 }), + ); + const manifestDenied = await handleLegacyArchiveManifest( + new Request("https://app.test/api/archive/manifest?siteId=site-1"), + env as any, + ); + expect(manifestDenied.status).toBe(403); + + vi.mocked(handlePrivateArchiveManifest).mockResolvedValueOnce( + new Response("not json", { status: 200 }), + ); + const invalidManifest = await handleLegacyArchiveManifest( + new Request("https://app.test/api/archive/manifest?siteId=site-1"), + env as any, + ); + expect(invalidManifest.status).toBe(502); + + const missingFileKey = await handleLegacyArchiveFile( + new Request("https://app.test/api/archive/file"), + env as any, + ); + expect(missingFileKey.status).toBe(400); + + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( + new Response("missing", { status: 404 }), + ); + const fileMissing = await handleLegacyArchiveFile( + new Request("https://app.test/api/archive/file?key=missing"), + env as any, + ); + expect(fileMissing.status).toBe(404); + + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( + new Response(new Uint8Array([1, 2, 3, 4]), { + headers: { "content-length": "4" }, + }), + ); + const head = await handleLegacyArchiveFile( + new Request("https://app.test/api/archive/file?key=archive-key", { + method: "HEAD", + }), + env as any, + ); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + expect(head.headers.get("content-type")).toBe( + "application/vnd.apache.parquet", + ); + }); +}); diff --git a/src/lib/edge/__tests__/query-core.test.ts b/src/lib/edge/__tests__/query-core.test.ts index ca78400c..68d141c6 100644 --- a/src/lib/edge/__tests__/query-core.test.ts +++ b/src/lib/edge/__tests__/query-core.test.ts @@ -17,6 +17,7 @@ import { emptyPerformanceRouteMetrics, eventPayloadFilterValueType, eventRecordOrderBy, + fetchPublicSite, finalizeDimensionBuckets, finalizeGeoDimensionBuckets, formatPageLabel, @@ -62,6 +63,7 @@ import { withoutFilterKey, withoutGeoFilter, } from "@/lib/edge/query/core"; +import type { Env } from "@/lib/edge/types"; const fixedNow = Date.UTC(2026, 4, 26, 8); @@ -268,6 +270,65 @@ describe("edge query core parsers", () => { }); }); +describe("edge public site lookup", () => { + function envWithPublicSite(row: Record | null) { + const first = vi.fn().mockResolvedValue(row); + const bind = vi.fn(() => ({ first })); + const prepare = vi.fn(() => ({ bind })); + const env = { DB: { prepare } } as unknown as Env; + return { env, prepare, bind, first }; + } + + it("requires enabled public sharing for slug lookup", async () => { + const { env, prepare, bind } = envWithPublicSite({ + id: "site-1", + name: "Blog", + domain: "blog.test", + }); + + const site = await fetchPublicSite( + env, + new URL("https://edge.test/api/public/blog/site"), + ); + + expect(site).toMatchObject({ id: "site-1" }); + expect(prepare).toHaveBeenCalledWith( + "SELECT id,name,domain FROM sites WHERE public_enabled=1 AND public_slug=? LIMIT 1", + ); + expect(bind).toHaveBeenCalledWith("blog"); + }); + + it("returns 404 for disabled, deleted, old, empty, or malformed slugs", async () => { + const { env, first } = envWithPublicSite(null); + + for (const path of [ + "/api/public/disabled/site", + "/api/public/old-slug/overview", + "/api/public/deleted/pages", + ]) { + const response = await fetchPublicSite( + env, + new URL(`https://edge.test${path}`), + ); + expect(response).toBeInstanceOf(Response); + expect((response as Response).status, path).toBe(404); + } + expect(first).toHaveBeenCalledTimes(3); + + const empty = await fetchPublicSite( + env, + new URL("https://edge.test/api/public/%20/site"), + ); + expect((empty as Response).status).toBe(404); + + const malformed = await fetchPublicSite( + env, + new URL("https://edge.test/api/public/%E0%A4%A/site"), + ); + expect((malformed as Response).status).toBe(404); + }); +}); + describe("edge query core dimensions", () => { it("formats page labels with optional query and hash details", () => { expect(formatPageLabel("", "", "", false)).toBe("/"); diff --git a/src/lib/edge/__tests__/query-entry.test.ts b/src/lib/edge/__tests__/query-entry.test.ts index 6199e6c6..a7f587bc 100644 --- a/src/lib/edge/__tests__/query-entry.test.ts +++ b/src/lib/edge/__tests__/query-entry.test.ts @@ -15,11 +15,21 @@ const routeQueryMock = vi.fn(); const handleTeamDashboardMock = vi.fn(); vi.mock("@/lib/edge/dashboard-cache", () => ({ + PUBLIC_QUERY_CACHE_OPTIONS: { + ttlSeconds: 300, + cacheName: "insightflare-public-query", + applyCacheHeadersOnBypass: true, + }, withDashboardCache: withDashboardCacheMock, })); vi.mock("@/lib/edge/query/core", () => ({ fetchPublicSite: fetchPublicSiteMock, + jsonResponse: (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }), notAllowed: () => new Response(JSON.stringify({ ok: false, error: "Method Not Allowed" }), { status: 405, @@ -212,7 +222,7 @@ describe("edge query entry handlers", () => { }); it("rejects unsupported public methods before public site lookup", async () => { - const edgeRequest = request("/api/public-sites/public/overview", { + const edgeRequest = request("/api/public/public/overview", { method: "POST", }); @@ -227,10 +237,10 @@ describe("edge query entry handlers", () => { expect(withDashboardCacheMock).not.toHaveBeenCalled(); }); - it("returns public site lookup responses without routing", async () => { + it("returns public site lookup responses without routing or cache lookup", async () => { const missing = new Response("missing", { status: 404 }); fetchPublicSiteMock.mockResolvedValueOnce(missing); - const edgeRequest = request("/api/public-sites/missing/overview"); + const edgeRequest = request("/api/public/missing/overview"); const response = await handlePublicQuery( edgeRequest, @@ -240,10 +250,11 @@ describe("edge query entry handlers", () => { expect(response).toBe(missing); expect(routeQueryMock).not.toHaveBeenCalled(); + expect(withDashboardCacheMock).not.toHaveBeenCalled(); }); it("routes public paths after the slug through dashboard cache", async () => { - const edgeRequest = request("/api/public-sites/public/pages/top"); + const edgeRequest = request("/api/public/public/pages"); const url = new URL(edgeRequest.url); const ctx = {} as ExecutionContext; @@ -254,14 +265,59 @@ describe("edge query entry handlers", () => { ctx, url, expect.any(Function), + { + ttlSeconds: 300, + cacheName: "insightflare-public-query", + applyCacheHeadersOnBypass: true, + }, ); expect(routeQueryMock).toHaveBeenCalledWith( env, "site-public", - "pages/top", + "pages", url, { publicMode: true }, edgeRequest, ); }); + + it("wraps public site metadata responses with public cache options", async () => { + const edgeRequest = request("/api/public/public/site"); + const url = new URL(edgeRequest.url); + const ctx = {} as ExecutionContext; + + const response = await handlePublicQuery(edgeRequest, env, url, ctx); + + await expect(response.json()).resolves.toMatchObject({ + ok: true, + data: { + slug: "public", + name: "Public", + domain: "public.example", + id: "site-public", + }, + }); + expect(withDashboardCacheMock).toHaveBeenCalledWith( + ctx, + url, + expect.any(Function), + { + ttlSeconds: 300, + cacheName: "insightflare-public-query", + applyCacheHeadersOnBypass: true, + }, + ); + expect(routeQueryMock).not.toHaveBeenCalled(); + }); + + it("decodes public site slugs in metadata responses", async () => { + const edgeRequest = request("/api/public/team%20site/site"); + const url = new URL(edgeRequest.url); + + const response = await handlePublicQuery(edgeRequest, env, url); + + await expect(response.json()).resolves.toMatchObject({ + data: { slug: "team site" }, + }); + }); }); diff --git a/src/lib/edge/__tests__/query-events-coverage.test.ts b/src/lib/edge/__tests__/query-events-coverage.test.ts index 7eb70fe9..817aa4d2 100644 --- a/src/lib/edge/__tests__/query-events-coverage.test.ts +++ b/src/lib/edge/__tests__/query-events-coverage.test.ts @@ -516,7 +516,7 @@ describe("edge query core event helper coverage", () => { ); expect(response.headers.get("cache-control")).toBe( - "public, max-age=60, s-maxage=60", + "public, max-age=300, s-maxage=300", ); await expect(response.json()).resolves.toMatchObject({ ok: true, diff --git a/src/lib/edge/__tests__/query-journey-d1.test.ts b/src/lib/edge/__tests__/query-journey-d1.test.ts index c888a9cd..0bfaa59e 100644 --- a/src/lib/edge/__tests__/query-journey-d1.test.ts +++ b/src/lib/edge/__tests__/query-journey-d1.test.ts @@ -350,6 +350,7 @@ describe("edge journey detail D1 queries", () => { region: "California", regionCode: "CA", city: "San Francisco", + pointCount: 1, }, ]); expect(detail?.events.map((event) => event.id)).toEqual([ @@ -461,6 +462,7 @@ describe("edge journey detail D1 queries", () => { region: "", regionCode: "", city: "", + pointCount: 1, }, ]); @@ -595,6 +597,7 @@ describe("edge journey geo D1 queries", () => { region: "", regionCode: "", city: "", + pointCount: 1, }, ], countryCounts: [], @@ -629,6 +632,7 @@ describe("edge journey geo D1 queries", () => { region: "", regionCode: "", city: "", + pointCount: 1, }, ], countryCounts: [{ country: "IT", views: 8, sessions: 4, visitors: 3 }], @@ -636,6 +640,10 @@ describe("edge journey geo D1 queries", () => { cityCounts: [], }); expect(calls[0].bindings).toEqual([...visitBindings(window), "it", 25]); + expect(calls[0].sql).not.toContain( + "WHERE LOWER(TRIM(COALESCE(country, ''))) = ?\n WHERE", + ); + expect(calls[0].sql).toContain("AND\n latitude IS NOT NULL"); expect(calls[1].sql).toContain("GROUP BY country"); }); diff --git a/src/lib/edge/__tests__/query-journey-helpers.test.ts b/src/lib/edge/__tests__/query-journey-helpers.test.ts index dbb39220..6a88dfd3 100644 --- a/src/lib/edge/__tests__/query-journey-helpers.test.ts +++ b/src/lib/edge/__tests__/query-journey-helpers.test.ts @@ -193,6 +193,7 @@ describe("edge journey helper branches", () => { region: "", regionCode: "", city: "", + pointCount: 1, }); expect( diff --git a/src/lib/edge/__tests__/query-journeys.test.ts b/src/lib/edge/__tests__/query-journeys.test.ts index 034ce282..1258bafb 100644 --- a/src/lib/edge/__tests__/query-journeys.test.ts +++ b/src/lib/edge/__tests__/query-journeys.test.ts @@ -149,6 +149,7 @@ describe("journey helper mappers", () => { region: "", regionCode: "", city: "", + pointCount: 1, }); }); diff --git a/src/lib/edge/__tests__/query-pages-overview-coverage.test.ts b/src/lib/edge/__tests__/query-pages-overview-coverage.test.ts index 3130d7f1..03b53299 100644 --- a/src/lib/edge/__tests__/query-pages-overview-coverage.test.ts +++ b/src/lib/edge/__tests__/query-pages-overview-coverage.test.ts @@ -1378,6 +1378,7 @@ describe("edge overview D1 queries and handlers", () => { region: "California", regionCode: "CA", city: "San Francisco", + pointCount: 1, }, ], countryCounts: [{ country: "US", views: 7, sessions: 4, visitors: 3 }], diff --git a/src/lib/edge/__tests__/query-router.test.ts b/src/lib/edge/__tests__/query-router.test.ts index 64bc8085..d5445a91 100644 --- a/src/lib/edge/__tests__/query-router.test.ts +++ b/src/lib/edge/__tests__/query-router.test.ts @@ -60,8 +60,8 @@ const handlerMocks = vi.hoisted(() => { handleBrowserVersionBreakdown: vi.fn( respond("browser-version-breakdown"), ), - handleClientCrossBreakdown: vi.fn(respond("client-cross-breakdown")), handleClientDimensionTrend: vi.fn(respond("client-dimension-trend")), + handleCrossBreakdown: vi.fn(respond("client-cross-breakdown")), handleReferrerDimensionTrend: vi.fn(respond("referrer-dimension-trend")), handleReferrerRadar: vi.fn(respond("referrer-radar")), handleUtmDimensionTrend: vi.fn(respond("utm-dimension-trend")), @@ -118,15 +118,100 @@ describe("edge query router", () => { expect(handlerMocks.core.notFound).not.toHaveBeenCalled(); }); + it("routes the public sharing query allowlist", async () => { + const publicPaths = [ + "overview", + "trend", + "pages", + "pages-dashboard", + "referrers", + "retention", + "performance", + "overview-geo-points", + "overview-geo-country", + "overview-source-domain", + "overview-source-link", + "overview-client-browser", + "overview-client-device-type", + "browser-trend", + "browser-engine-trend", + "browser-version-breakdown", + "browser-cross-breakdown", + "browser-radar", + "referrer-radar", + "referrer-dimension-trend", + "client-dimension-trend", + "client-cross-breakdown", + "utm-source", + "utm-medium", + "utm-campaign", + "utm-term", + "utm-content", + "utm-dimension-trend", + "page-query", + "page-hash", + "event-types", + ]; + + for (const path of publicPaths) { + const response = await route(path, true); + expect(response.status, path).toBe(200); + } + }); + + it("blocks sensitive detail queries in public mode", async () => { + const blockedPaths = [ + "funnels", + "sessions", + "session-detail", + "visitors", + "visitor-detail", + "events-records", + "event-record-detail", + ]; + + for (const path of blockedPaths) { + const response = await route(path, true); + expect(response.status, path).toBe(404); + } + + expect(handlerMocks.funnels.handleFunnel).not.toHaveBeenCalled(); + expect(handlerMocks.journeys.handleSessions).not.toHaveBeenCalled(); + expect(handlerMocks.journeys.handleSessionDetail).not.toHaveBeenCalled(); + expect(handlerMocks.journeys.handleVisitors).not.toHaveBeenCalled(); + expect(handlerMocks.journeys.handleVisitorDetail).not.toHaveBeenCalled(); + expect(handlerMocks.events.handleEventsRecords).not.toHaveBeenCalled(); + expect(handlerMocks.events.handleEventRecordDetail).not.toHaveBeenCalled(); + }); + it("blocks all non-public routes before dispatching handlers", async () => { - const response = await route("pages-dashboard", true); + const response = await route("sessions", true); expect(response.status).toBe(404); await expect(response.text()).resolves.toBe("not-found"); - expect(handlerMocks.pages.handlePagesDashboard).not.toHaveBeenCalled(); + expect(handlerMocks.journeys.handleSessions).not.toHaveBeenCalled(); expect(handlerMocks.core.notFound).toHaveBeenCalledTimes(1); }); + it("keeps campaign, retention, and page detail support queries available to public routes", async () => { + await expect(responseText("retention", true)).resolves.toBe("retention"); + await expect(responseText("utm-source", true)).resolves.toBe("dimension"); + await expect(responseText("utm-dimension-trend", true)).resolves.toBe( + "utm-dimension-trend", + ); + await expect(responseText("event-types", true)).resolves.toBe( + "event-types", + ); + + expect(handlerMocks.journeys.handleRetention).toHaveBeenCalledTimes(1); + expect(handlerMocks.pages.handleDimension).toHaveBeenCalledTimes(1); + expect( + handlerMocks.technology.handleUtmDimensionTrend, + ).toHaveBeenCalledTimes(1); + expect(handlerMocks.events.handleEventTypes).toHaveBeenCalledTimes(1); + expect(handlerMocks.core.notFound).not.toHaveBeenCalled(); + }); + it("routes private dashboard, event, journey, performance, and technology paths", async () => { await expect(responseText("pages-dashboard")).resolves.toBe( "pages-dashboard", diff --git a/src/lib/edge/__tests__/query-technology-coverage.test.ts b/src/lib/edge/__tests__/query-technology-coverage.test.ts index f8bef9d2..2b05844d 100644 --- a/src/lib/edge/__tests__/query-technology-coverage.test.ts +++ b/src/lib/edge/__tests__/query-technology-coverage.test.ts @@ -13,6 +13,7 @@ import { SHARE_TREND_OTHER_LABEL, SHARE_TREND_OTHER_TOKEN, } from "@/lib/edge/query/core"; +import { clientDimensionDefinition } from "@/lib/edge/query/core"; import { queryBrowserCrossBreakdownFromD1, queryBrowserCrossDimensionFromD1, @@ -20,7 +21,7 @@ import { queryBrowserTrendFromD1, queryBrowserVersionBreakdownFromD1, } from "@/lib/edge/query/technology/browser"; -import { queryClientCrossDimensionFromD1 } from "@/lib/edge/query/technology/client-cross"; +import { queryCrossDimensionFromD1 } from "@/lib/edge/query/technology/client-cross"; import { queryBrowserRadarFromD1, queryReferrerRadarFromD1, @@ -589,15 +590,15 @@ describe("edge technology query coverage", () => { ], ]); - const result = await queryClientCrossDimensionFromD1( + const result = await queryCrossDimensionFromD1( env, siteId, window, {}, 3, 2, - "browser", - "deviceType", + clientDimensionDefinition("browser"), + clientDimensionDefinition("deviceType"), ); expect(result.columns).toEqual([ @@ -670,27 +671,27 @@ describe("edge technology query coverage", () => { ]); await expect( - queryClientCrossDimensionFromD1( + queryCrossDimensionFromD1( noPrimary.env, siteId, window, {}, 3, 3, - "browser", - "deviceType", + clientDimensionDefinition("browser"), + clientDimensionDefinition("deviceType"), ), ).resolves.toEqual({ columns: [], rows: [], totalVisitors: 0 }); await expect( - queryClientCrossDimensionFromD1( + queryCrossDimensionFromD1( noSecondary.env, siteId, window, {}, 3, 3, - "browser", - "deviceType", + clientDimensionDefinition("browser"), + clientDimensionDefinition("deviceType"), ), ).resolves.toEqual({ columns: [], rows: [], totalVisitors: 0 }); @@ -738,15 +739,15 @@ describe("edge technology query coverage", () => { ]); await expect( - queryClientCrossDimensionFromD1( + queryCrossDimensionFromD1( env, siteId, window, {}, 2, 1, - "deviceType", - "browser", + clientDimensionDefinition("deviceType"), + clientDimensionDefinition("browser"), ), ).resolves.toMatchObject({ columns: [{ key: "chrome", label: "Chrome" }], @@ -1190,15 +1191,15 @@ describe("edge technology query coverage", () => { ]); await expect( - queryClientCrossDimensionFromD1( + queryCrossDimensionFromD1( env, siteId, window, {}, 3, 3, - "browser", - "deviceType", + clientDimensionDefinition("browser"), + clientDimensionDefinition("deviceType"), ), ).resolves.toEqual({ columns: [ diff --git a/src/lib/edge/__tests__/query-technology-handlers.test.ts b/src/lib/edge/__tests__/query-technology-handlers.test.ts index 92a1c8a0..a33a200c 100644 --- a/src/lib/edge/__tests__/query-technology-handlers.test.ts +++ b/src/lib/edge/__tests__/query-technology-handlers.test.ts @@ -11,8 +11,8 @@ import { handleBrowserRadar, handleBrowserTrend, handleBrowserVersionBreakdown, - handleClientCrossBreakdown, handleClientDimensionTrend, + handleCrossBreakdown, handleReferrerDimensionTrend, handleReferrerRadar, handleUtmDimensionTrend, @@ -25,7 +25,7 @@ const queryMocks = vi.hoisted(() => ({ queryBrowserRadarFromD1: vi.fn(), queryBrowserTrendFromD1: vi.fn(), queryBrowserVersionBreakdownFromD1: vi.fn(), - queryClientCrossDimensionFromD1: vi.fn(), + queryCrossDimensionFromD1: vi.fn(), queryClientDimensionTrendFromD1: vi.fn(), queryReferrerRadarFromD1: vi.fn(), queryReferrerTrendFromD1: vi.fn(), @@ -41,7 +41,7 @@ vi.mock("@/lib/edge/query/technology/browser", () => ({ })); vi.mock("@/lib/edge/query/technology/client-cross", () => ({ - queryClientCrossDimensionFromD1: queryMocks.queryClientCrossDimensionFromD1, + queryCrossDimensionFromD1: queryMocks.queryCrossDimensionFromD1, })); vi.mock("@/lib/edge/query/technology/radar", () => ({ @@ -95,7 +95,7 @@ function expectNoQueryCalls() { expect(queryMocks.queryBrowserRadarFromD1).not.toHaveBeenCalled(); expect(queryMocks.queryBrowserTrendFromD1).not.toHaveBeenCalled(); expect(queryMocks.queryBrowserVersionBreakdownFromD1).not.toHaveBeenCalled(); - expect(queryMocks.queryClientCrossDimensionFromD1).not.toHaveBeenCalled(); + expect(queryMocks.queryCrossDimensionFromD1).not.toHaveBeenCalled(); expect(queryMocks.queryClientDimensionTrendFromD1).not.toHaveBeenCalled(); expect(queryMocks.queryReferrerRadarFromD1).not.toHaveBeenCalled(); expect(queryMocks.queryReferrerTrendFromD1).not.toHaveBeenCalled(); @@ -141,8 +141,11 @@ describe("edge query technology handlers", () => { ["referrer dimension trend", handleReferrerDimensionTrend, {}], [ "client cross breakdown", - handleClientCrossBreakdown, - { primaryDimension: "browser", secondaryDimension: "deviceType" }, + handleCrossBreakdown, + { + primaryDimension: "client.browser", + secondaryDimension: "client.deviceType", + }, ], ])("rejects invalid time windows for %s", async (_label, handler, params) => { const response = await handler( @@ -536,29 +539,38 @@ describe("edge query technology handlers", () => { }); it("rejects invalid or duplicate client cross breakdown dimensions", async () => { - const invalidPrimary = await handleClientCrossBreakdown( + const unsupportedPrimary = await handleCrossBreakdown( env, siteId, - testUrl({ primaryDimension: "country", secondaryDimension: "browser" }), + testUrl({ + primaryDimension: "session.entryPath", + secondaryDimension: "client.browser", + }), ); - const invalidSecondary = await handleClientCrossBreakdown( + const unsupportedSecondary = await handleCrossBreakdown( env, siteId, - testUrl({ primaryDimension: "browser", secondaryDimension: "country" }), + testUrl({ + primaryDimension: "client.browser", + secondaryDimension: "event.name", + }), ); - const duplicate = await handleClientCrossBreakdown( + const duplicate = await handleCrossBreakdown( env, siteId, - testUrl({ primaryDimension: "browser", secondaryDimension: "browser" }), + testUrl({ + primaryDimension: "client.browser", + secondaryDimension: "client.browser", + }), ); - expect(await responseJson(invalidPrimary)).toMatchObject({ + expect(await responseJson(unsupportedPrimary)).toMatchObject({ ok: false, - error: { message: "Invalid primary dimension" }, + error: { message: "Unsupported primary dimension" }, }); - expect(await responseJson(invalidSecondary)).toMatchObject({ + expect(await responseJson(unsupportedSecondary)).toMatchObject({ ok: false, - error: { message: "Invalid secondary dimension" }, + error: { message: "Unsupported secondary dimension" }, }); expect(await responseJson(duplicate)).toMatchObject({ ok: false, @@ -568,16 +580,14 @@ describe("edge query technology handlers", () => { }); it("passes parsed client cross breakdown arguments and returns enveloped data", async () => { - queryMocks.queryClientCrossDimensionFromD1.mockResolvedValue( - emptyCrossData, - ); + queryMocks.queryCrossDimensionFromD1.mockResolvedValue(emptyCrossData); - const response = await handleClientCrossBreakdown( + const response = await handleCrossBreakdown( env, siteId, testUrl({ - primaryDimension: "browser", - secondaryDimension: "deviceType", + primaryDimension: "client.browser", + secondaryDimension: "client.deviceType", primaryLimit: "99", secondaryLimit: "0", }), @@ -588,15 +598,15 @@ describe("edge query technology handlers", () => { ok: true, data: emptyCrossData, }); - expect(queryMocks.queryClientCrossDimensionFromD1).toHaveBeenCalledWith( + expect(queryMocks.queryCrossDimensionFromD1).toHaveBeenCalledWith( env, siteId, parsedWindow(), expect.any(Object), 12, 1, - "browser", - "deviceType", + expect.objectContaining({ fallbackKeyBase: "browser" }), + expect.objectContaining({ fallbackKeyBase: "device" }), ); }); }); diff --git a/src/lib/edge/__tests__/query.test.ts b/src/lib/edge/__tests__/query.test.ts index 0a938b54..5e79210e 100644 --- a/src/lib/edge/__tests__/query.test.ts +++ b/src/lib/edge/__tests__/query.test.ts @@ -12,6 +12,11 @@ vi.mock("@/lib/edge/session-auth", () => ({ })); vi.mock("@/lib/edge/dashboard-cache", () => ({ + PUBLIC_QUERY_CACHE_OPTIONS: { + ttlSeconds: 300, + cacheName: "insightflare-public-query", + applyCacheHeadersOnBypass: true, + }, withDashboardCache: vi.fn( async ( _ctx: ExecutionContext | undefined, @@ -1429,14 +1434,14 @@ describe("edge query handlers", () => { const clientCross = await privateQuery( privatePath( "client-cross-breakdown", - "primaryDimension=browser&secondaryDimension=deviceType", + "primaryDimension=client.browser&secondaryDimension=client.deviceType", ), env, ); const invalidClientCross = await privateQuery( privatePath( "client-cross-breakdown", - "primaryDimension=browser&secondaryDimension=browser", + "primaryDimension=client.browser&secondaryDimension=client.browser", ), env, ); @@ -1877,7 +1882,7 @@ describe("edge query handlers", () => { }); const overview = await publicQuery(publicPath("overview"), env); - const privateOnly = await publicQuery(publicPath("event-types"), env); + const privateOnly = await publicQuery(publicPath("events-summary"), env); const missingSlug = await publicQuery( `/api/public-sites/%20/overview?${windowParams}`, env, diff --git a/src/lib/edge/admin-ws.ts b/src/lib/edge/admin-ws.ts new file mode 100644 index 00000000..32c317d4 --- /dev/null +++ b/src/lib/edge/admin-ws.ts @@ -0,0 +1,177 @@ +import type { Env } from "./types"; + +function base64UrlDecode(input: string): Uint8Array { + const padded = + input.replace(/-/g, "+").replace(/_/g, "/") + + "===".slice((input.length + 3) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + +async function hmacSha256( + message: string, + secret: string, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(message), + ); + return new Uint8Array(sig); +} + +async function verifySessionToken( + token: string, + secret: string, +): Promise | null> { + if (!token || token.length < 20) return null; + const [payloadPart, signaturePart] = token.split("."); + if (!payloadPart || !signaturePart) return null; + + const expectedSig = await hmacSha256(payloadPart, secret); + let actualSig: Uint8Array; + try { + actualSig = base64UrlDecode(signaturePart); + } catch { + return null; + } + if (!bytesEqual(expectedSig, actualSig)) return null; + + try { + const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadPart)); + const parsed = JSON.parse(payloadJson) as Record; + if (!parsed || typeof parsed !== "object") return null; + + const { userId, username, exp } = parsed; + if (!userId || !username || !exp) return null; + if (Math.floor(Date.now() / 1000) >= Number(exp)) return null; + + return parsed as Record; + } catch { + return null; + } +} + +async function deriveSessionSecret(env: Env): Promise { + const explicit = env.DASHBOARD_SESSION_SECRET || env.SESSION_SECRET; + if (explicit) return explicit; + + const root = env.MAIN_SECRET || env.DAILY_SALT_SECRET; + if (!root) return null; + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(root), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode("insightflare:dashboard-session:v1"), + ); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function extractSessionToken(request: Request): string { + const auth = request.headers.get("authorization") || ""; + if (auth.toLowerCase().startsWith("bearer ")) { + return auth.slice(7).trim(); + } + + const cookie = request.headers.get("cookie") || ""; + if (!cookie) return ""; + const parts = cookie.split(";"); + for (const part of parts) { + const [rawKey, ...rawValue] = part.trim().split("="); + if (rawKey === "if_session") { + try { + return decodeURIComponent(rawValue.join("=")); + } catch { + return rawValue.join("="); + } + } + } + return ""; +} + +async function canSessionReadSite( + env: Env, + session: Record, + siteId: string, +): Promise { + if (session.systemRole === "admin") { + const site = await env.DB.prepare("SELECT id FROM sites WHERE id=? LIMIT 1") + .bind(siteId) + .first<{ id: string }>(); + return Boolean(site?.id); + } + + const site = await env.DB.prepare( + `SELECT s.id + FROM sites s + INNER JOIN teams t ON t.id = s.team_id + LEFT JOIN team_members tm ON tm.team_id = s.team_id AND tm.user_id = ? + WHERE s.id = ? AND (t.owner_user_id = ? OR tm.user_id IS NOT NULL) + LIMIT 1`, + ) + .bind(session.userId, siteId, session.userId) + .first<{ id: string }>(); + + return Boolean(site?.id); +} + +export async function handleAdminWs( + request: Request, + env: Env, +): Promise { + const secret = await deriveSessionSecret(env); + if (!secret) { + return new Response("Service unavailable", { status: 503 }); + } + + const token = extractSessionToken(request); + const session = await verifySessionToken(token, secret); + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const incomingUrl = new URL(request.url); + const siteId = incomingUrl.searchParams.get("siteId"); + if (!siteId) { + return new Response("siteId is required", { status: 400 }); + } + + const allowed = await canSessionReadSite(env, session, siteId); + if (!allowed) { + return new Response("Forbidden", { status: 403 }); + } + + const doId = env.INGEST_DO.idFromName(siteId); + const stub = env.INGEST_DO.get(doId); + const forwardUrl = "https://ingest.internal/ws" + incomingUrl.search; + return stub.fetch(new Request(forwardUrl, request)); +} diff --git a/src/lib/edge/admin.ts b/src/lib/edge/admin.ts index b9dca65c..e83fd279 100644 --- a/src/lib/edge/admin.ts +++ b/src/lib/edge/admin.ts @@ -20,6 +20,9 @@ import { } from "./admin-users"; import type { Env } from "./types"; +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handlePrivateAdmin( request: Request, env: Env, diff --git a/src/lib/edge/api-v1-helpers.ts b/src/lib/edge/api-v1-helpers.ts index b516c303..2394a9e7 100644 --- a/src/lib/edge/api-v1-helpers.ts +++ b/src/lib/edge/api-v1-helpers.ts @@ -454,6 +454,28 @@ export function validateDimension( }); } +const UNSUPPORTED_CROSS_BREAKDOWN = new Set([ + "session.entryPath", + "session.exitPath", + "event.name", +]); + +export function validateCrossBreakdownDimension( + value: string, +): AnalyticsDimension | Response { + const base = validateDimension(value); + if (base instanceof Response) return base; + if (UNSUPPORTED_CROSS_BREAKDOWN.has(value)) { + return jsonError( + "validation_failed", + "Dimension not supported for cross-breakdowns", + 400, + { dimension: value }, + ); + } + return base; +} + export function parseFilter(url: URL): Record | Response { const filters: Record = {}; for (const [key, value] of url.searchParams.entries()) { diff --git a/src/lib/edge/api-v1.ts b/src/lib/edge/api-v1.ts index 574b8450..e8a12324 100644 --- a/src/lib/edge/api-v1.ts +++ b/src/lib/edge/api-v1.ts @@ -8,7 +8,26 @@ import { import { SiteCreateInputSchema, SiteUpdateInputSchema } from "@/schemas/site"; import { SiteConfigUpdateInputSchema } from "@/schemas/site-config"; +import { + buildTimeBuckets, + buildVisitFilterSql, + buildVisitSourceCte, + buildVisitSourceCteForSites, + parseFilters, + parseInterval, + PERFORMANCE_METRIC_COLUMNS, + type PerformanceMetricKey, + queryD1All, + type QueryWindow, + resolveCrossBreakdownDimension, + visitSourceBindings, + visitSourceBindingsForSites, +} from "./query/core"; import { normalizeFunnelSteps, queryFunnelAnalysis } from "./query/funnels"; +import { + queryPerformanceSummariesFromD1, + queryPerformanceTrendFromD1, +} from "./query/performance"; import { routeQuery } from "./query/router"; import { handleTeamDashboardForTeam } from "./query/team"; import { @@ -29,6 +48,7 @@ import { type AnalyticsMetric, API_V1_VERSION, BATCH_MAX_REQUESTS, + type ComplexFilter, epochSecondsToIso, FILTER_OPERATORS, INTERVALS, @@ -47,6 +67,7 @@ import { parseTimeRange, requireScope, TIME_PRESETS, + validateCrossBreakdownDimension, validateDimension, } from "./api-v1-helpers"; import { @@ -159,7 +180,7 @@ function apiBase(url: URL): string { return url.pathname.replace(/^\/api\/v1\/?/, ""); } -function segments(url: URL): string[] { +export function apiV1Segments(url: URL): string[] { return apiBase(url) .split("/") .map((segment) => { @@ -549,6 +570,568 @@ function normalizeBreakdownRows(value: unknown, metrics: AnalyticsMetric[]) { }); } +interface AnalyticsOrderBy { + field: string; + direction: "asc" | "desc"; +} + +function sqlWhereWithExtra(baseClause: string, extraClause: string): string { + if (!extraClause) return baseClause; + if (baseClause.trim()) return `${baseClause} AND ${extraClause}`; + return `WHERE ${extraClause}`; +} + +function analyticsMetricSql(metric: AnalyticsMetric): string { + const sessions = + "COUNT(DISTINCT CASE WHEN scoped.session_id != '' THEN scoped.session_id ELSE NULL END)"; + const bounces = + "COUNT(DISTINCT CASE WHEN bounced_sessions.session_id IS NOT NULL THEN scoped.session_id ELSE NULL END)"; + + if (metric === "views") return "COUNT(*)"; + if (metric === "sessions") return sessions; + if (metric === "visitors") { + return "COUNT(DISTINCT CASE WHEN scoped.visitor_id != '' THEN scoped.visitor_id ELSE NULL END)"; + } + if (metric === "bounces") return bounces; + if (metric === "bounceRate") { + return `CASE WHEN ${sessions} > 0 THEN CAST(${bounces} AS REAL) / ${sessions} ELSE 0 END`; + } + if (metric === "avgDurationMs") { + return `CASE WHEN ${sessions} > 0 THEN ROUND(COALESCE(SUM(CASE WHEN scoped.duration_ms IS NOT NULL AND scoped.duration_ms >= 0 THEN scoped.duration_ms ELSE 0 END), 0) / ${sessions}) ELSE 0 END`; + } + if (metric === "viewsPerSession") { + return `CASE WHEN ${sessions} > 0 THEN CAST(COUNT(*) AS REAL) / ${sessions} ELSE 0 END`; + } + return "COALESCE(SUM(event_rollup.event_count), 0)"; +} + +function validateAnalyticsDimensions( + dimensions: string[], + request: Request, +): Response | null { + for (const dimension of dimensions) { + const valid = validateDimension(dimension); + if (valid instanceof Response) return valid; + if (!resolveCrossBreakdownDimension(dimension)) { + return jsonError( + "validation_failed", + "Unsupported dimension", + 400, + { dimension }, + request, + ); + } + } + return null; +} + +function parseExploreMetrics(value: unknown): AnalyticsMetric[] | Response { + if (value === undefined) return ["views"]; + if (!Array.isArray(value) || value.length === 0 || value.length > 20) { + return jsonError("validation_failed", "Invalid metrics", 400, { + field: "metrics", + }); + } + const invalid = value.find( + (metric) => + typeof metric !== "string" || + !ANALYTICS_METRICS.includes(metric as AnalyticsMetric), + ); + if (invalid !== undefined) { + return jsonError("validation_failed", "Unknown metric", 400, { + metric: String(invalid), + }); + } + return [...new Set(value)] as AnalyticsMetric[]; +} + +function parseExploreDimensions(value: unknown): string[] | Response { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 5) { + return jsonError("validation_failed", "Invalid dimensions", 400, { + field: "dimensions", + }); + } + const invalid = value.find((dimension) => typeof dimension !== "string"); + if (invalid !== undefined) { + return jsonError("validation_failed", "Invalid dimension", 400, { + dimension: String(invalid), + }); + } + return [...new Set(value)] as string[]; +} + +function parseExploreOrderBy(value: unknown): AnalyticsOrderBy[] | Response { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 5) { + return jsonError("validation_failed", "Invalid orderBy", 400, { + field: "orderBy", + }); + } + const orderBy: AnalyticsOrderBy[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + return jsonError("validation_failed", "Invalid orderBy", 400); + } + const record = item as Record; + const field = typeof record.field === "string" ? record.field : ""; + const direction = record.direction === "asc" ? "asc" : "desc"; + if (!field) { + return jsonError("validation_failed", "Invalid orderBy field", 400); + } + orderBy.push({ field, direction }); + } + return orderBy; +} + +function parseExploreLimit(value: unknown): number | Response { + if (value === undefined) return 100; + const limit = Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + return jsonError("validation_failed", "Invalid limit", 400, { + field: "limit", + }); + } + return limit; +} + +function urlWithBodyTimeRange(url: URL, record: Record): URL { + const timeRange = + record.timeRange && typeof record.timeRange === "object" + ? (record.timeRange as Record) + : null; + if (!timeRange) return url; + const next = new URL(url.toString()); + for (const key of ["from", "to", "preset", "timeZone"] as const) { + if (typeof timeRange[key] === "string") { + next.searchParams.set(key, timeRange[key]); + } + } + return next; +} + +function complexFilterSql( + filters: ComplexFilter[], + request: Request, +): { clause: string; bindings: Array } | Response { + const clauses: string[] = []; + const bindings: Array = []; + for (const filter of filters) { + const definition = resolveCrossBreakdownDimension(filter.field); + if (!definition) { + return jsonError( + "validation_failed", + "Unsupported filter field", + 400, + { field: filter.field }, + request, + ); + } + const expr = definition.labelExpr; + const value = filter.value; + const bindScalar = (raw: unknown) => { + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "boolean") return raw ? 1 : 0; + if (raw === null || raw === undefined) return ""; + return String(raw); + }; + + if (filter.op === "exists") { + clauses.push(`TRIM(COALESCE(${expr}, '')) != ''`); + continue; + } + if (filter.op === "notExists") { + clauses.push(`TRIM(COALESCE(${expr}, '')) = ''`); + continue; + } + if (filter.op === "in" || filter.op === "notIn") { + const values = Array.isArray(value) ? value : []; + if (values.length === 0) { + clauses.push(filter.op === "in" ? "1 = 0" : "1 = 1"); + continue; + } + clauses.push( + `${expr} ${filter.op === "in" ? "IN" : "NOT IN"} (${values + .map(() => "?") + .join(", ")})`, + ); + bindings.push(...values.map(bindScalar)); + continue; + } + if (filter.op === "contains") { + clauses.push(`${expr} LIKE ?`); + bindings.push(`%${bindScalar(value)}%`); + continue; + } + if (filter.op === "startsWith") { + clauses.push(`${expr} LIKE ?`); + bindings.push(`${bindScalar(value)}%`); + continue; + } + if (filter.op === "endsWith") { + clauses.push(`${expr} LIKE ?`); + bindings.push(`%${bindScalar(value)}`); + continue; + } + const operator = + filter.op === "neq" + ? "!=" + : filter.op === "gt" + ? ">" + : filter.op === "gte" + ? ">=" + : filter.op === "lt" + ? "<" + : filter.op === "lte" + ? "<=" + : "="; + clauses.push(`${expr} ${operator} ?`); + bindings.push(bindScalar(value)); + } + return { clause: clauses.join(" AND "), bindings }; +} + +async function queryAnalyticsAggregateRows( + env: Env, + siteIds: string[], + window: QueryWindow, + url: URL, + request: Request, + options: { + dimensions: string[]; + metrics: AnalyticsMetric[]; + complexFilters?: ComplexFilter[]; + limit: number; + orderBy?: AnalyticsOrderBy[]; + }, +): Promise> | Response> { + if (siteIds.length === 0) return []; + const invalidDimension = validateAnalyticsDimensions( + options.dimensions, + request, + ); + if (invalidDimension) return invalidDimension; + + const dimensionDefs = options.dimensions.map((dimension) => ({ + dimension, + definition: resolveCrossBreakdownDimension(dimension)!, + })); + const filters = buildVisitFilterSql(parseFilters(url)); + const complex = complexFilterSql(options.complexFilters ?? [], request); + if (complex instanceof Response) return complex; + const whereClause = sqlWhereWithExtra(filters.clause, complex.clause); + const sourceCte = + siteIds.length === 1 + ? buildVisitSourceCte() + : buildVisitSourceCteForSites(siteIds.length); + const sourceBindings = + siteIds.length === 1 + ? visitSourceBindings(siteIds[0]!, window) + : visitSourceBindingsForSites(siteIds, window); + const eventSitePlaceholders = siteIds.map(() => "?").join(", "); + const dimensionSelects = dimensionDefs.map( + ({ definition }, index) => `${definition.labelExpr} AS d${index}`, + ); + const groupColumns = dimensionDefs.map((_, index) => `scoped.d${index}`); + const metricSelects = options.metrics.map( + (metric) => `${analyticsMetricSql(metric)} AS ${metric}`, + ); + const selectColumns = [...groupColumns, ...metricSelects]; + const allowedOrderFields = new Set([ + ...options.metrics, + ...options.dimensions, + ]); + const orderBy = (options.orderBy ?? []).filter((item) => + allowedOrderFields.has(item.field as AnalyticsMetric), + ); + const orderSql = + orderBy.length > 0 + ? orderBy + .map((item) => { + const dimensionIndex = options.dimensions.indexOf(item.field); + const column = + dimensionIndex >= 0 ? `scoped.d${dimensionIndex}` : item.field; + return `${column} ${item.direction.toUpperCase()}`; + }) + .join(", ") + : options.metrics.length > 0 + ? `${options.metrics[0]} DESC` + : groupColumns.join(", "); + const sql = ` +WITH +${sourceCte}, +scoped AS ( + SELECT + visit_source.* + ${dimensionSelects.length ? `,\n ${dimensionSelects.join(",\n ")}` : ""} + FROM visit_source + ${whereClause} +), +bounced_sessions AS ( + SELECT session_id + FROM visit_source + WHERE session_id != '' + GROUP BY session_id + HAVING COUNT(*) = 1 +), +event_rollup AS ( + SELECT visit_id, COUNT(*) AS event_count + FROM custom_events + WHERE site_id IN (${eventSitePlaceholders}) AND occurred_at BETWEEN ? AND ? + GROUP BY visit_id +) +SELECT + ${selectColumns.join(",\n ")} +FROM scoped +LEFT JOIN bounced_sessions ON bounced_sessions.session_id = scoped.session_id +LEFT JOIN event_rollup ON event_rollup.visit_id = scoped.visit_id +${groupColumns.length ? `GROUP BY ${groupColumns.join(", ")}` : ""} +ORDER BY ${orderSql || "views DESC"} +LIMIT ? +`; + const rows = await queryD1All>(env, sql, [ + ...sourceBindings, + ...filters.bindings, + ...complex.bindings, + ...siteIds, + window.fromMs, + window.toMs, + options.limit, + ]); + return rows.map((row) => { + const out: Record = {}; + options.dimensions.forEach((dimension, index) => { + out[dimension] = String(row[`d${index}`] ?? ""); + }); + for (const metric of options.metrics) { + out[metric] = Number(row[metric] ?? 0); + } + return out; + }); +} + +async function queryTeamAnalyticsBreakdown( + env: Env, + siteIds: string[], + window: QueryWindow, + url: URL, + request: Request, + dimension: AnalyticsDimension, + metrics: AnalyticsMetric[], +): Promise> | Response> { + const limit = parseExploreLimit(Number(url.searchParams.get("limit") ?? 100)); + if (limit instanceof Response) return limit; + const rows = await queryAnalyticsAggregateRows( + env, + siteIds, + window, + url, + request, + { + dimensions: [dimension], + metrics, + limit, + }, + ); + if (rows instanceof Response) return rows; + return rows.map((row) => { + const normalized = normalizeUnknownDirect(row[dimension]); + const metricsOut: Record = {}; + for (const metric of metrics) metricsOut[metric] = row[metric]; + return { + key: normalized.key, + label: normalized.label, + ...metricsOut, + }; + }); +} + +function parsePerformanceMetric(url: URL): PerformanceMetricKey | Response { + const metric = url.searchParams.get("metric") || "lcp"; + if (metric in PERFORMANCE_METRIC_COLUMNS) + return metric as PerformanceMetricKey; + return jsonError("validation_failed", "Invalid performance metric", 400, { + metric, + }); +} + +function performanceSummaryValue(row: { + p75: number | null; + avg: number | null; +}): number | null { + return row.p75 ?? row.avg; +} + +async function queryPerformanceSummaryData( + env: Env, + siteId: string, + window: QueryWindow, + url: URL, +) { + const summaries = await queryPerformanceSummariesFromD1( + env, + siteId, + window, + parseFilters(url), + ); + return { + ttfb: performanceSummaryValue(summaries.ttfb), + fcp: performanceSummaryValue(summaries.fcp), + lcp: performanceSummaryValue(summaries.lcp), + cls: performanceSummaryValue(summaries.cls), + inp: performanceSummaryValue(summaries.inp), + details: summaries, + }; +} + +async function queryPerformanceTimeseriesData( + env: Env, + siteId: string, + window: QueryWindow, + url: URL, +) { + const filters = parseFilters(url); + const interval = parseInterval(url); + const buckets = buildTimeBuckets(window, interval); + const metricKeys = Object.keys( + PERFORMANCE_METRIC_COLUMNS, + ) as PerformanceMetricKey[]; + const series = await Promise.all( + metricKeys.map((metric) => + queryPerformanceTrendFromD1( + env, + siteId, + window, + interval, + filters, + metric, + ), + ), + ); + const rows = new Map>(); + for (const [metricIndex, points] of series.entries()) { + const metric = metricKeys[metricIndex]!; + for (const point of points) { + const bucket = buckets[point.bucket] ?? { + timestampMs: point.timestampMs, + toMs: point.timestampMs + 1, + }; + const row = + rows.get(point.bucket) ?? + ({ + start: new Date(bucket.timestampMs).toISOString(), + end: new Date(bucket.toMs).toISOString(), + } satisfies Record); + row[metric] = performanceSummaryValue(point); + rows.set(point.bucket, row); + } + } + return { + interval, + rows: [...rows.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, row]) => row), + }; +} + +async function queryPerformanceBreakdownData( + env: Env, + siteId: string, + window: QueryWindow, + url: URL, + request: Request, + dimension: AnalyticsDimension, +): Promise> | Response> { + const metric = parsePerformanceMetric(url); + if (metric instanceof Response) return metric; + const definition = resolveCrossBreakdownDimension(dimension); + if (!definition) { + return jsonError( + "validation_failed", + "Unsupported performance breakdown dimension", + 400, + { dimension }, + request, + ); + } + const filters = buildVisitFilterSql(parseFilters(url)); + const whereClause = sqlWhereWithExtra( + filters.clause, + `${PERFORMANCE_METRIC_COLUMNS[metric]} IS NOT NULL`, + ); + const limit = parseExploreLimit(Number(url.searchParams.get("limit") ?? 100)); + if (limit instanceof Response) return limit; + const sql = ` +WITH +${buildVisitSourceCte()}, +scoped AS ( + SELECT + ${definition.labelExpr} AS dimensionValue, + ${PERFORMANCE_METRIC_COLUMNS[metric]} AS metricValue + FROM visit_source + ${whereClause} +), +dimension_views AS ( + SELECT dimensionValue, COUNT(*) AS views + FROM scoped + GROUP BY dimensionValue +), +ordered_values AS ( + SELECT + dimensionValue, + metricValue, + ROW_NUMBER() OVER (PARTITION BY dimensionValue ORDER BY metricValue ASC) AS rowNum, + COUNT(*) OVER (PARTITION BY dimensionValue) AS sampleCount + FROM scoped +), +thresholds AS ( + SELECT + dimensionValue, + sampleCount, + AVG(metricValue) AS avgValue, + CAST(((sampleCount * 50) + 99) / 100 AS INTEGER) AS p50Rank, + CAST(((sampleCount * 75) + 99) / 100 AS INTEGER) AS p75Rank, + CAST(((sampleCount * 95) + 99) / 100 AS INTEGER) AS p95Rank + FROM ordered_values + GROUP BY dimensionValue, sampleCount +) +SELECT + thresholds.dimensionValue AS dimensionValue, + dimension_views.views AS views, + thresholds.sampleCount AS samples, + thresholds.avgValue AS avg, + MIN(CASE WHEN ordered_values.rowNum >= thresholds.p50Rank THEN ordered_values.metricValue END) AS p50, + MIN(CASE WHEN ordered_values.rowNum >= thresholds.p75Rank THEN ordered_values.metricValue END) AS p75, + MIN(CASE WHEN ordered_values.rowNum >= thresholds.p95Rank THEN ordered_values.metricValue END) AS p95 +FROM thresholds +JOIN ordered_values ON ordered_values.dimensionValue = thresholds.dimensionValue +JOIN dimension_views ON dimension_views.dimensionValue = thresholds.dimensionValue +GROUP BY thresholds.dimensionValue, thresholds.sampleCount, thresholds.avgValue, dimension_views.views +ORDER BY p75 DESC, views DESC, thresholds.dimensionValue ASC +LIMIT ? +`; + const rows = await queryD1All>(env, sql, [ + ...visitSourceBindings(siteId, window), + ...filters.bindings, + limit, + ]); + return rows.map((row) => { + const normalized = normalizeUnknownDirect(row.dimensionValue); + const p75 = Number(row.p75 ?? 0); + return { + key: normalized.key, + label: normalized.label, + views: Number(row.views ?? 0), + [metric]: p75, + avg: Number(row.avg ?? 0), + p50: Number(row.p50 ?? 0), + p75, + p95: Number(row.p95 ?? 0), + samples: Number(row.samples ?? 0), + }; + }); +} + function normalizeTimeseriesRows(value: unknown) { if (!Array.isArray(value)) return []; return value.map((row) => { @@ -629,7 +1212,7 @@ function requireSiteScope( return requireScope(principal.scopes, scope, request); } -async function handleRoot(request: Request): Promise { +export async function handleRoot(request: Request): Promise { if (request.method !== "GET") return methodNotAllowed(request); return jsonSuccess( { @@ -649,7 +1232,7 @@ async function handleRoot(request: Request): Promise { ); } -async function handleToken( +export async function handleToken( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -678,7 +1261,7 @@ async function handleToken( ); } -async function handleTokenCheck( +export async function handleTokenCheck( request: Request, principal: ApiKeyPrincipal, ): Promise { @@ -719,7 +1302,7 @@ async function handleTokenCheck( ); } -async function handleCapabilities( +export async function handleCapabilities( request: Request, principal: ApiKeyPrincipal, ): Promise { @@ -760,7 +1343,7 @@ async function handleCapabilities( ); } -async function handleTeam( +export async function handleTeam( request: Request, env: Env, url: URL, @@ -814,6 +1397,25 @@ async function handleTeamAnalytics( const resource = path[2]; const timeRange = parseTimeRange(url); if (timeRange instanceof Response) return timeRange; + if (resource === "breakdowns" && path[3]) { + const dimension = validateDimension(path[3]); + if (dimension instanceof Response) return dimension; + const metrics = parseMetrics(url.searchParams.get("metrics")); + if (metrics instanceof Response) return metrics; + const sites = await listSites(env, principal); + const internalUrl = buildInternalUrl(url, timeRange); + const rows = await queryTeamAnalyticsBreakdown( + env, + sites.map((site) => site.id), + toQueryWindow(timeRange), + internalUrl, + request, + dimension, + metrics, + ); + if (rows instanceof Response) return rows; + return jsonList(rows, { request, meta: { timeRange, dimension, metrics } }); + } const internalUrl = buildInternalUrl(url, timeRange); const dashboard = await handleTeamDashboardForTeam( env, @@ -931,11 +1533,6 @@ async function handleTeamAnalytics( { request, meta: { timeRange } }, ); } - if (resource === "breakdowns" && path[3]) { - const dimension = validateDimension(path[3]); - if (dimension instanceof Response) return dimension; - return jsonList([], { request, meta: { timeRange, dimension } }); - } return jsonError( "resource_not_found", "Resource not found", @@ -945,7 +1542,7 @@ async function handleTeamAnalytics( ); } -async function handleSitesCollection( +export async function handleSitesCollection( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1007,7 +1604,7 @@ async function handleSitesCollection( return methodNotAllowed(request); } -async function handleSiteResource( +export async function handleSiteResource( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1084,7 +1681,7 @@ async function handleSiteResource( return methodNotAllowed(request); } -async function handleTracking( +export async function handleTracking( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1120,7 +1717,7 @@ async function handleTracking( return methodNotAllowed(request); } -async function handlePrivacy( +export async function handlePrivacy( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1165,7 +1762,7 @@ async function handlePrivacy( return methodNotAllowed(request); } -async function handleSharing( +export async function handleSharing( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1222,7 +1819,7 @@ async function handleSharing( return methodNotAllowed(request); } -async function handleTrackingScript( +export async function handleTrackingScript( request: Request, env: Env, url: URL, @@ -1288,7 +1885,7 @@ function analyticsSchema(siteId: string) { }; } -async function handleAnalytics( +export async function handleAnalytics( request: Request, env: Env, url: URL, @@ -1364,8 +1961,10 @@ async function handleAnalytics( } if (resource === "cross-breakdowns") { if (request.method !== "GET") return methodNotAllowed(request); - const primary = validateDimension(url.searchParams.get("primary") || ""); - const secondary = validateDimension( + const primary = validateCrossBreakdownDimension( + url.searchParams.get("primary") || "", + ); + const secondary = validateCrossBreakdownDimension( url.searchParams.get("secondary") || "", ); if (primary instanceof Response) return primary; @@ -1401,16 +2000,44 @@ async function handleAnalytics( if (body instanceof Response) return body; const record = body && typeof body === "object" ? (body as Record) : {}; + const bodyUrl = urlWithBodyTimeRange(url, record); + const exploreTimeRange = parseTimeRange(bodyUrl); + if (exploreTimeRange instanceof Response) return exploreTimeRange; + const metrics = parseExploreMetrics(record.metrics); + if (metrics instanceof Response) return metrics; + const dimensions = parseExploreDimensions(record.dimensions); + if (dimensions instanceof Response) return dimensions; + const invalidDimension = validateAnalyticsDimensions(dimensions, request); + if (invalidDimension) return invalidDimension; const complexFilters = parseComplexFilters(record.filters); if (complexFilters instanceof Response) return complexFilters; + const orderBy = parseExploreOrderBy(record.orderBy); + if (orderBy instanceof Response) return orderBy; + const limit = parseExploreLimit(record.limit); + if (limit instanceof Response) return limit; + const rows = await queryAnalyticsAggregateRows( + env, + [siteId], + toQueryWindow(exploreTimeRange), + buildInternalUrl(bodyUrl, exploreTimeRange), + request, + { + dimensions, + metrics, + complexFilters, + limit, + orderBy, + }, + ); + if (rows instanceof Response) return rows; return jsonSuccess( { - rows: [], - metrics: Array.isArray(record.metrics) ? record.metrics : [], - dimensions: Array.isArray(record.dimensions) ? record.dimensions : [], + rows, + metrics, + dimensions, filters: complexFilters, }, - { request, meta: { timeRange } }, + { request, meta: { timeRange: exploreTimeRange } }, ); } if (resource === "retention" && path[4] === "cohorts") { @@ -1430,7 +2057,7 @@ async function handleAnalytics( ); } -async function handleEvents( +export async function handleEvents( request: Request, env: Env, url: URL, @@ -1521,7 +2148,7 @@ async function handleEvents( ); } -async function handleJourneys( +export async function handleJourneys( request: Request, env: Env, url: URL, @@ -1766,7 +2393,7 @@ async function handleFunnelResource( return methodNotAllowed(request); } -async function handleFunnels( +export async function handleFunnels( request: Request, env: Env, url: URL, @@ -1843,25 +2470,74 @@ async function handleFunnels( ); } -async function handlePerformance( +export async function handlePerformance( request: Request, env: Env, url: URL, principal: ApiKeyPrincipal, siteId: string, + path: string[], ): Promise { const site = await ensureAnalyticsAccess(request, env, principal, siteId); if (site instanceof Response) return site; if (request.method !== "GET") return methodNotAllowed(request); const timeRange = parseTimeRange(url); if (timeRange instanceof Response) return timeRange; - return runLegacyQuery(request, env, siteId, url, "performance", { - timeRange, - meta: { timeRange }, - }); + const filters = parseFilter(url); + if (filters instanceof Response) return filters; + const window = toQueryWindow(timeRange); + const internalUrl = buildInternalUrl(url, timeRange); + const resource = path[3] || "summary"; + if (resource === "summary") { + const data = await queryPerformanceSummaryData( + env, + siteId, + window, + internalUrl, + ); + return jsonSuccess(data, { request, meta: { timeRange } }); + } + if (resource === "timeseries") { + const data = await queryPerformanceTimeseriesData( + env, + siteId, + window, + internalUrl, + ); + return jsonList(data.rows, { + request, + meta: { timeRange, interval: data.interval }, + }); + } + if (resource === "breakdowns" && path[4]) { + const dimension = validateDimension(path[4]); + if (dimension instanceof Response) return dimension; + const metric = parsePerformanceMetric(url); + if (metric instanceof Response) return metric; + const rows = await queryPerformanceBreakdownData( + env, + siteId, + window, + internalUrl, + request, + dimension, + ); + if (rows instanceof Response) return rows; + return jsonList(rows, { + request, + meta: { timeRange, dimension, metric }, + }); + } + return jsonError( + "resource_not_found", + "Resource not found", + 404, + undefined, + request, + ); } -async function handleRealtime( +export async function handleRealtime( request: Request, env: Env, url: URL, @@ -1904,11 +2580,18 @@ async function handleRealtime( ); } -async function handleBatch( +export type ApiV1BatchDispatcher = ( + request: Request, + env: Env, + url: URL, +) => Promise; + +export async function handleBatch( request: Request, env: Env, url: URL, _principal: ApiKeyPrincipal, + dispatch: ApiV1BatchDispatcher = handleApiV1, ): Promise { if (request.method !== "POST") return methodNotAllowed(request); const body = await parseJsonBody(request); @@ -1960,7 +2643,7 @@ async function handleBatch( method: "GET", headers: request.headers, }); - const response = await handleApiV1(subRequest, env, subUrl); + const response = await dispatch(subRequest, env, subUrl); return { id: item.id, status: response.status, @@ -1979,13 +2662,16 @@ async function handleBatch( ); } +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handleApiV1( request: Request, env: Env, url: URL, ctx?: ExecutionContext, ): Promise { - const path = segments(url); + const path = apiV1Segments(url); if (path.length === 0) return handleRoot(request); const principal = await authenticateApiKey(request, env, ctx); @@ -2052,7 +2738,7 @@ export async function handleApiV1( return handleFunnels(request, env, url, principal, siteId, path); } if (path[2] === "performance") { - return handlePerformance(request, env, url, principal, siteId); + return handlePerformance(request, env, url, principal, siteId, path); } if (path[2] === "realtime") { return handleRealtime(request, env, url, principal, siteId, path); diff --git a/src/lib/edge/archive-query.ts b/src/lib/edge/archive-query.ts index bc50f86f..b2d5c97c 100644 --- a/src/lib/edge/archive-query.ts +++ b/src/lib/edge/archive-query.ts @@ -84,7 +84,7 @@ function parseWindowHours( }; } -async function handleManifest( +export async function handlePrivateArchiveManifest( request: Request, env: Env, url: URL, @@ -161,7 +161,7 @@ async function handleManifest( }); } -async function handleFile( +export async function handlePrivateArchiveFile( request: Request, env: Env, url: URL, @@ -247,6 +247,9 @@ async function handleFile( return new Response(object.body, { status, headers }); } +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handlePrivateArchive( request: Request, env: Env, @@ -254,10 +257,10 @@ export async function handlePrivateArchive( ): Promise { const pathname = url.pathname; if (pathname === "/api/private/archive/manifest") { - return handleManifest(request, env, url); + return handlePrivateArchiveManifest(request, env, url); } if (pathname === "/api/private/archive/file") { - return handleFile(request, env, url); + return handlePrivateArchiveFile(request, env, url); } return notFound(); diff --git a/src/lib/edge/collect.ts b/src/lib/edge/collect.ts new file mode 100644 index 00000000..ceff6bf2 --- /dev/null +++ b/src/lib/edge/collect.ts @@ -0,0 +1,596 @@ +import { isBot } from "ua-parser-js/bot-detection"; + +import { normalizeTrackerUaClientHints } from "@/lib/edge/client-hints"; +import { expandCustomEventData } from "@/lib/edge/custom-event-json"; +import { + normalizeSiteSettingsKey, + readSiteTrackingConfig, +} from "@/lib/edge/site-settings-store"; +import type { Env } from "@/lib/edge/types"; +import type { + IngestEnvelopePayload, + IngestTracePayload, + SerializedRequestPayload, + TrackerClientPayload, +} from "@/lib/edge/types"; +import type { TrackerPayloadKind } from "@/lib/edge/types"; +import { jsonCloneRecord } from "@/lib/edge/utils"; +import { assertContentSize, BODY_SIZE_LIMITS } from "@/lib/form-helpers"; +import { jsonResponse } from "@/lib/response"; +import type { SiteTrackingConfig } from "@/lib/site-settings"; + +const CORS_BASE_HEADERS = { + "access-control-allow-methods": "GET, POST, PATCH, OPTIONS", + "access-control-allow-headers": "content-type", + "access-control-max-age": "86400", +}; + +const SUPPORTED_KINDS = new Set([ + "pageview", + "leave", + "visibility", + "custom_event", + "identify", +]); + +function pickSiteIdFromPayload( + payload: TrackerClientPayload, + requestUrl: URL, +): string { + if (typeof payload.siteId === "string" && payload.siteId.length > 0) { + return payload.siteId; + } + const fromQuery = requestUrl.searchParams.get("siteId"); + if (fromQuery && fromQuery.length > 0) { + return fromQuery; + } + return "default"; +} + +function sanitizeInputPayload(payload: unknown): TrackerClientPayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + return payload as TrackerClientPayload; +} + +function coerceTrimmedString(input: unknown, maxLength: number): string { + if (typeof input !== "string") return ""; + return input.trim().slice(0, maxLength); +} + +function isSupportedKind(input: unknown): input is TrackerPayloadKind { + return ( + typeof input === "string" && + SUPPORTED_KINDS.has(input as TrackerPayloadKind) + ); +} + +function normalizeClientHostname(input: unknown): string { + const value = coerceTrimmedString(input, 255) + .toLowerCase() + .replace(/\.+$/, ""); + if (!value || value.includes("/") || value.includes(":")) return ""; + return value; +} + +function normalizePayloadPathname(input: unknown): string { + let value = coerceTrimmedString(input, 4096); + if (!value) value = "/"; + + if (value.includes("://")) { + try { + value = new URL(value).pathname || "/"; + } catch { + return ""; + } + } + + value = value.split(/[?#]/)[0] ?? value; + value = value.trim().replace(/\s+/g, ""); + if (!value) value = "/"; + if (!value.startsWith("/")) value = `/${value.replace(/^\/+/, "")}`; + value = value.replace(/\/{2,}/g, "/"); + return value.slice(0, 2048); +} + +function matchesBlockedPath(pathname: string, blockedPaths: string[]): boolean { + for (const blockedPath of blockedPaths) { + if (!blockedPath) continue; + if (pathname === blockedPath || pathname.startsWith(`${blockedPath}/`)) { + return true; + } + } + return false; +} + +function serializeHeaders(request: Request): Record { + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + return headers; +} + +function serializeRequestPayload( + request: Request, + body: string, +): SerializedRequestPayload { + return { + method: request.method, + url: request.url, + headers: serializeHeaders(request), + cf: jsonCloneRecord((request as Request & { cf?: unknown }).cf), + body, + receivedAt: Date.now(), + }; +} + +function parseOrigin(request: Request): string | null { + const raw = (request.headers.get("origin") || "").trim(); + if (!raw) return null; + try { + return new URL(raw).origin; + } catch { + return null; + } +} + +function parseOriginHostname(origin: string | null): string { + if (!origin) return ""; + try { + return new URL(origin).hostname.trim().toLowerCase().replace(/\.+$/, ""); + } catch { + return ""; + } +} + +function toCorsHeaders(origin: string | null): Record { + if (!origin) { + return { + ...CORS_BASE_HEADERS, + vary: "Origin", + }; + } + return { + ...CORS_BASE_HEADERS, + "access-control-allow-origin": origin, + vary: "Origin", + }; +} + +function isBotRequest(request: Request): boolean { + const ua = request.headers.get("user-agent") || ""; + if (!ua || !isBot(ua)) return false; + console.log(`[Bot] UA: ${ua}`); + return true; +} + +type CollectionDecision = + | { + shouldForward: false; + allowOrigin: string | null; + siteId: string; + payload: null; + reason: string; + detail?: Record; + } + | { + shouldForward: true; + allowOrigin: string | null; + siteId: string; + payload: TrackerClientPayload; + }; + +async function decideCollectionPolicy( + request: Request, + env: Env, + payload: TrackerClientPayload | null, + requestUrl: URL, +): Promise { + const origin = parseOrigin(request); + const originHostname = parseOriginHostname(origin); + if (!payload) { + return { + shouldForward: false, + allowOrigin: origin, + siteId: "", + payload: null, + reason: "missing_payload", + }; + } + + const kind = payload.kind; + if (!isSupportedKind(kind)) { + return { + shouldForward: false, + allowOrigin: origin, + siteId: "", + payload: null, + reason: "unsupported_kind", + detail: { kind: String(kind || "") }, + }; + } + + const siteId = normalizeSiteSettingsKey( + pickSiteIdFromPayload(payload, requestUrl), + ); + if (!siteId) { + return { + shouldForward: false, + allowOrigin: origin, + siteId: "", + payload: null, + reason: "missing_site_id", + }; + } + + let settings = null; + try { + // `readSiteTrackingConfig` already caches KV results for 1 hour. + settings = await readSiteTrackingConfig(env, siteId); + } catch (error) { + logIngestTrace("collect_settings_read_failed", { + siteId, + error: errorToMessage(error), + }); + settings = null; + } + + if (!settings?.siteDomain) { + return { + shouldForward: false, + allowOrigin: origin, + siteId, + payload: null, + reason: "missing_site_settings", + }; + } + + const hasWhitelist = + Array.isArray(settings.domainWhitelist) && + settings.domainWhitelist.length > 0; + if ( + hasWhitelist && + !settings.allowedHostnames.some( + (hostname) => hostname.trim().toLowerCase() === originHostname, + ) + ) { + return { + shouldForward: false, + allowOrigin: origin, + siteId, + payload: null, + reason: "origin_not_allowed", + detail: { + origin, + originHostname, + allowedHostnames: settings.allowedHostnames, + }, + }; + } + + const normalizedPayloadResult = normalizeForwardPayload( + payload, + siteId, + kind, + settings, + ); + if (!normalizedPayloadResult.payload) { + return { + shouldForward: false, + allowOrigin: origin, + siteId, + payload: null, + reason: normalizedPayloadResult.reason, + detail: normalizedPayloadResult.detail, + }; + } + + return { + shouldForward: true, + allowOrigin: origin, + siteId, + payload: normalizedPayloadResult.payload, + }; +} + +function normalizeForwardPayload( + payload: TrackerClientPayload, + siteId: string, + kind: TrackerPayloadKind, + settings: SiteTrackingConfig, +): { + payload: TrackerClientPayload | null; + reason: string; + detail?: Record; +} { + const visitId = coerceTrimmedString(payload.visitId, 128); + if (!visitId) return { payload: null, reason: "missing_visit_id" }; + + const normalizedPayload: TrackerClientPayload = { + ...payload, + siteId, + kind, + visitId, + }; + const uaClientHints = normalizeTrackerUaClientHints(payload.uaClientHints); + if (uaClientHints) { + normalizedPayload.uaClientHints = uaClientHints; + } else { + delete normalizedPayload.uaClientHints; + } + + const canCheckPath = + kind === "pageview" || + kind === "custom_event" || + kind === "visibility" || + (kind === "leave" && + coerceTrimmedString(payload.pathname, 4096).length > 0); + + if (canCheckPath) { + const pathname = normalizePayloadPathname(payload.pathname); + if (!pathname) { + return { + payload: null, + reason: "invalid_pathname", + detail: { pathname: String(payload.pathname || "") }, + }; + } + if (matchesBlockedPath(pathname, settings.pathBlacklist)) { + return { + payload: null, + reason: "blocked_pathname", + detail: { pathname }, + }; + } + normalizedPayload.pathname = pathname; + } + + if (kind === "pageview") { + const hostname = normalizeClientHostname(payload.hostname); + if (!hostname) { + return { + payload: null, + reason: "missing_hostname", + detail: { hostname: String(payload.hostname || "") }, + }; + } + normalizedPayload.hostname = hostname; + } + + if (kind === "custom_event") { + const eventName = coerceTrimmedString(payload.eventName, 120); + if (!eventName) return { payload: null, reason: "missing_event_name" }; + normalizedPayload.eventName = eventName; + } + + if (kind === "visibility") { + const visibilityState = coerceTrimmedString(payload.visibilityState, 20); + if (visibilityState !== "hidden" && visibilityState !== "visible") { + return { + payload: null, + reason: "invalid_visibility_state", + detail: { visibilityState }, + }; + } + normalizedPayload.visibilityState = visibilityState; + } + + return { payload: normalizedPayload, reason: "" }; +} + +function createTraceId(): string { + try { + return crypto.randomUUID(); + } catch { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } +} + +function errorToMessage(error: unknown): string { + return String(error instanceof Error ? error.message : error); +} + +function logIngestTrace( + event: string, + fields: Record = {}, + level: "info" | "warn" | "error" = "info", +): void { + const payload = { + event, + at: new Date().toISOString(), + ...fields, + }; + const line = JSON.stringify(payload); + if (level === "error") { + console.error(line); + return; + } + if (level === "warn") { + console.warn(line); + return; + } + console.log(line); +} + +function compactPayloadForLog( + payload: TrackerClientPayload | null, +): Record { + if (!payload) return {}; + return { + kind: payload.kind || "", + siteId: payload.siteId || "", + visitId: payload.visitId || "", + previousVisitId: payload.previousVisitId || "", + eventId: payload.eventId || "", + eventName: payload.eventName || "", + visibilityState: payload.visibilityState || "", + pathname: payload.pathname || "", + hostname: payload.hostname || "", + timestamp: payload.timestamp ?? null, + }; +} + +function noContent(origin: string | null): Response { + return new Response(null, { status: 204, headers: toCorsHeaders(origin) }); +} + +function jsonError( + origin: string | null, + message: string, + status: 400 | 413 | 422 = 400, +): Response { + return jsonResponse( + { ok: false, error: message }, + status, + toCorsHeaders(origin), + ); +} + +export async function handleCollectOptionsRequest( + request: Request, +): Promise { + return noContent(parseOrigin(request)); +} + +export async function handleCollectRequest( + request: Request, + env: Env, + ctx: ExecutionContext, + url = new URL(request.url), +): Promise { + // Body 大小限制检查 + const sizeError = assertContentSize(request, BODY_SIZE_LIMITS.COLLECT); + if (sizeError) return sizeError; + + const requestWithCf = request; + const origin = parseOrigin(requestWithCf); + const trace: IngestTracePayload = { + id: createTraceId(), + source: "collect", + acceptedAt: Date.now(), + }; + + if (isBotRequest(requestWithCf)) { + logIngestTrace("collect_rejected", { + traceId: trace.id, + reason: "bot", + origin, + userAgent: requestWithCf.headers.get("user-agent") || "", + }); + return noContent(origin); + } + + const body = await requestWithCf.text(); + let payload: TrackerClientPayload | null = null; + if (body) { + try { + payload = sanitizeInputPayload(JSON.parse(body)); + } catch (error) { + logIngestTrace( + "collect_rejected", + { + traceId: trace.id, + reason: "invalid_json", + origin, + bodyBytes: body.length, + error: errorToMessage(error), + }, + "warn", + ); + return jsonError(origin, "Invalid JSON payload", 400); + } + } + + if (payload?.kind === "custom_event") { + const eventDataResult = expandCustomEventData(payload.eventData); + if (!eventDataResult.ok) { + logIngestTrace( + "collect_rejected", + { + traceId: trace.id, + reason: "invalid_custom_event_data", + ...compactPayloadForLog(payload), + error: eventDataResult.error, + }, + "warn", + ); + return jsonError(origin, eventDataResult.error, eventDataResult.status); + } + } + + const decision = await decideCollectionPolicy( + requestWithCf, + env, + payload, + url, + ); + if (!decision.shouldForward) { + logIngestTrace("collect_rejected", { + traceId: trace.id, + reason: decision.reason, + origin, + siteId: decision.siteId, + ...compactPayloadForLog(payload), + ...(decision.detail || {}), + }); + return noContent(decision.allowOrigin); + } + + const doId = env.INGEST_DO.idFromName(decision.siteId); + const stub = env.INGEST_DO.get(doId); + + const envelope: IngestEnvelopePayload = { + request: serializeRequestPayload(requestWithCf, body), + client: decision.payload, + trace, + }; + + logIngestTrace("collect_forward_queued", { + traceId: trace.id, + origin, + ...compactPayloadForLog(decision.payload), + }); + + ctx.waitUntil( + stub + .fetch("https://ingest.internal/ingest", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(envelope), + }) + .then(async (response) => { + const bodyText = await response.text().catch(() => ""); + logIngestTrace( + response.ok ? "collect_forward_result" : "collect_forward_failed", + { + traceId: trace.id, + siteId: decision.siteId, + kind: decision.payload.kind || "", + visitId: decision.payload.visitId || "", + status: response.status, + response: bodyText.slice(0, 200), + }, + response.ok ? "info" : "error", + ); + }) + .catch((error: unknown) => { + logIngestTrace( + "collect_forward_failed", + { + traceId: trace.id, + siteId: decision.siteId, + kind: decision.payload.kind || "", + visitId: decision.payload.visitId || "", + error: errorToMessage(error), + }, + "error", + ); + }), + ); + + return noContent(decision.allowOrigin); +} diff --git a/src/lib/edge/dashboard-cache.ts b/src/lib/edge/dashboard-cache.ts index 3ef12638..b30fe3d5 100644 --- a/src/lib/edge/dashboard-cache.ts +++ b/src/lib/edge/dashboard-cache.ts @@ -1,4 +1,4 @@ -// Edge-cache helper for read-only private dashboard queries. +// Edge-cache helper for read-only dashboard and public share queries. // // Goal: hot dashboards (Devices, Browsers, Geo, etc.) currently fan out into // 10–20 D1 statements per page load and re-issue the same SQL on every @@ -13,6 +13,13 @@ const DASHBOARD_CACHE_NAME = "insightflare-dashboard-query"; const DEFAULT_TTL_SECONDS = 60; +const PUBLIC_QUERY_CACHE_NAME = "insightflare-public-query"; +export const PUBLIC_QUERY_CACHE_TTL_SECONDS = 300; +export const PUBLIC_QUERY_CACHE_OPTIONS = { + ttlSeconds: PUBLIC_QUERY_CACHE_TTL_SECONDS, + cacheName: PUBLIC_QUERY_CACHE_NAME, + applyCacheHeadersOnBypass: true, +} as const; function openCacheStorage(): CacheStorage | null { if (typeof globalThis !== "object" || !("caches" in globalThis)) { @@ -25,11 +32,11 @@ function openCacheStorage(): CacheStorage | null { return maybeCaches; } -async function openEdgeCache(): Promise { +async function openEdgeCache(cacheName: string): Promise { const storage = openCacheStorage(); if (!storage) return null; try { - return await storage.open(DASHBOARD_CACHE_NAME); + return await storage.open(cacheName); } catch { return null; } @@ -51,7 +58,7 @@ function buildCacheKeyRequest(url: URL): Request { function withCacheControlHeaders( response: Response, ttlSeconds: number, - marker: "HIT" | "MISS", + marker?: "HIT" | "MISS", ): Response { const headers = new Headers(response.headers); headers.set( @@ -60,7 +67,9 @@ function withCacheControlHeaders( ); // Strip per-user vary so the edge can actually share the entry. headers.delete("vary"); - headers.set("x-edge-cache", marker); + if (marker) { + headers.set("x-edge-cache", marker); + } return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -70,6 +79,8 @@ function withCacheControlHeaders( export interface DashboardCacheOptions { ttlSeconds?: number; + cacheName?: string; + applyCacheHeadersOnBypass?: boolean; } /** @@ -90,9 +101,13 @@ export async function withDashboardCache( 1, Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), ); - const cache = await openEdgeCache(); + const cache = await openEdgeCache(options.cacheName ?? DASHBOARD_CACHE_NAME); if (!cache) { - return generate(); + const fresh = await generate(); + if (options.applyCacheHeadersOnBypass && fresh.ok) { + return withCacheControlHeaders(fresh, ttlSeconds); + } + return fresh; } const cacheKey = buildCacheKeyRequest(url); diff --git a/src/lib/edge/legacy-admin.ts b/src/lib/edge/legacy-admin.ts new file mode 100644 index 00000000..9c769b79 --- /dev/null +++ b/src/lib/edge/legacy-admin.ts @@ -0,0 +1,421 @@ +import { toTeamRole } from "@/lib/dashboard/permissions"; +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { bad, jsonResponseFor } from "@/lib/edge/admin-response"; +import type { Env } from "@/lib/edge/types"; +import { requireSameOrigin } from "@/lib/edge/utils"; +import { + assertContentSize, + BODY_SIZE_LIMITS, + bodyStr, + parseFormBool, + parseRequestBody, +} from "@/lib/form-helpers"; +import { errorResponse, normalizeErrorMessage } from "@/lib/response"; + +type AdminMethod = "POST" | "PATCH"; + +async function callPrivateAdmin( + request: Request, + env: Env, + pathname: string, + method: AdminMethod, + body: Record, + legacyErrorCode: string, +): Promise { + const url = new URL(pathname, request.url); + const headers = new Headers(request.headers); + headers.set("content-type", "application/json"); + const subRequest = new Request(url, { + method, + headers, + body: JSON.stringify(body), + }); + const response = await handlePrivateAdmin(subRequest, env, url); + const text = await response.text(); + if (!response.ok) { + return errorResponse( + request, + 500, + legacyErrorCode, + normalizeErrorMessage(text), + ); + } + try { + const payload = JSON.parse(text) as { data?: T }; + return payload.data as T; + } catch { + return errorResponse( + request, + 500, + legacyErrorCode, + "Private admin response payload is invalid JSON", + ); + } +} + +async function parseLegacyAdminBody( + request: Request, +): Promise | Response> { + const sizeError = assertContentSize(request, BODY_SIZE_LIMITS.ADMIN_API); + if (sizeError) return sizeError; + + const csrfError = requireSameOrigin(request); + if (csrfError) return csrfError; + + return parseRequestBody(request); +} + +function buildLegacyConfig( + body: Record, +): Record { + return { + privacy: { + maskQueryHashDetails: parseFormBool(body.maskQueryHashDetails, true), + maskVisitorTrajectory: parseFormBool(body.maskVisitorTrajectory, true), + maskDetailedReferrerUrl: parseFormBool( + body.maskDetailedReferrerUrl, + true, + ), + }, + }; +} + +export async function handleLegacyAdminUser( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const intent = bodyStr(body, "intent") || "create"; + + if (intent === "remove" || intent === "delete") { + const userId = bodyStr(body, "userId"); + if (!userId) return bad("Missing user ID", "missing_user_id", request); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/users", + "PATCH", + { userId, intent: "remove" }, + "user_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (intent === "update") { + const userId = bodyStr(body, "userId"); + if (!userId) return bad("Missing user ID", "missing_user_id", request); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/users", + "PATCH", + { + userId, + username: bodyStr(body, "username") || undefined, + email: bodyStr(body, "email") || undefined, + name: bodyStr(body, "name") || undefined, + password: bodyStr(body, "password") || undefined, + systemRole: + bodyStr(body, "systemRole").toLowerCase() === "admin" + ? "admin" + : "user", + }, + "user_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + const username = bodyStr(body, "username"); + const email = bodyStr(body, "email"); + const password = String(body.password ?? ""); + const name = bodyStr(body, "name"); + const systemRole = + bodyStr(body, "systemRole").toLowerCase() === "admin" ? "admin" : "user"; + if (!username || !email || password.length < 8) { + return bad("Invalid user input", "invalid_user_input", request); + } + + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/users", + "POST", + { username, email, password, name: name || undefined, systemRole }, + "user_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} + +export async function handleLegacyAdminTeam( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const intent = bodyStr(body, "intent"); + const teamId = bodyStr(body, "teamId"); + const name = bodyStr(body, "name"); + const slug = bodyStr(body, "slug"); + + if (intent === "transfer_owner") { + const newOwnerUserId = bodyStr(body, "newOwnerUserId"); + if (!teamId || !newOwnerUserId) { + return bad("Missing transfer input", "missing_transfer_input", request); + } + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/teams", + "PATCH", + { teamId, newOwnerUserId, intent: "transfer_owner" }, + "transfer_team_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (intent === "remove" || intent === "delete") { + if (!teamId) return bad("Missing team ID", "missing_team_id", request); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/teams", + "PATCH", + { teamId, intent: "remove" }, + "remove_team_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (name.length < 2) { + return bad("Invalid team name", "invalid_team_name", request); + } + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/teams", + teamId ? "PATCH" : "POST", + teamId ? { teamId, name, slug: slug || undefined } : { name, slug }, + teamId ? "update_team_failed" : "create_team_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} + +export async function handleLegacyAdminSite( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const intent = bodyStr(body, "intent") || "create"; + const teamId = bodyStr(body, "teamId"); + const siteId = bodyStr(body, "siteId"); + const name = bodyStr(body, "name"); + const domain = bodyStr(body, "domain"); + const publicEnabled = parseFormBool(body.publicEnabled); + const publicSlug = bodyStr(body, "publicSlug"); + + if (intent === "remove") { + if (!siteId) return bad("Missing site ID", "missing_site_id", request); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/sites", + "PATCH", + { siteId, intent: "remove" }, + "site_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (intent === "update") { + if (!siteId) return bad("Missing site ID", "missing_site_id", request); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/sites", + "PATCH", + { + siteId, + teamId: teamId || undefined, + name: name || undefined, + domain: domain || undefined, + publicEnabled, + publicSlug: publicSlug || undefined, + }, + "site_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (!teamId || !name || !domain) { + return bad("Invalid site input", "invalid_site_input", request); + } + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/sites", + "POST", + { + teamId, + name, + domain, + publicEnabled, + publicSlug: publicSlug || undefined, + }, + "site_mutation_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} + +export async function handleLegacyAdminMember( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const intent = bodyStr(body, "intent") || "add"; + const teamId = bodyStr(body, "teamId"); + + if (intent === "remove") { + const userId = bodyStr(body, "userId"); + if (!teamId || !userId) { + return bad( + "Invalid member remove input", + "invalid_member_remove_input", + request, + ); + } + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/members", + "PATCH", + { teamId, userId }, + "remove_member_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + if (intent === "update_role") { + const userId = bodyStr(body, "userId"); + const role = toTeamRole(bodyStr(body, "role")); + if (!teamId || !userId || role === "owner") { + return bad( + "Invalid member role input", + "invalid_member_role_input", + request, + ); + } + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/members", + "PATCH", + { teamId, userId, role, intent: "update_role" }, + "update_member_role_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); + } + + const identifier = bodyStr(body, "identifier"); + if (!teamId || identifier.length < 2) { + return bad("Invalid member input", "invalid_member_input", request); + } + + const requestedRoleRaw = bodyStr(body, "role"); + const requestedRole = requestedRoleRaw ? toTeamRole(requestedRoleRaw) : null; + if (requestedRole === "owner") { + return bad("Cannot assign owner role", "invalid_member_input", request); + } + + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/members", + "POST", + requestedRole + ? { teamId, identifier, role: requestedRole } + : { teamId, identifier }, + "add_member_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} + +export async function handleLegacyAdminSiteConfig( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const siteId = bodyStr(body, "siteId"); + if (!siteId) return bad("Missing site ID", "missing_site_id", request); + + const config = + body.config && typeof body.config === "object" + ? (body.config as Record) + : buildLegacyConfig(body); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/site-config", + "POST", + { siteId, config }, + "save_site_config_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} + +export async function handleLegacyAdminProfile( + request: Request, + env: Env, +): Promise { + const body = await parseLegacyAdminBody(request); + if (body instanceof Response) return body; + const hasTimeZone = Object.prototype.hasOwnProperty.call(body, "timeZone"); + const hasName = Object.prototype.hasOwnProperty.call(body, "name"); + const result = await callPrivateAdmin( + request, + env, + "/api/private/admin/profile", + "POST", + { + username: bodyStr(body, "username") || undefined, + email: bodyStr(body, "email") || undefined, + name: hasName ? bodyStr(body, "name") : undefined, + currentPassword: bodyStr(body, "currentPassword") || undefined, + password: bodyStr(body, "password") || undefined, + ...(hasTimeZone ? { timeZone: bodyStr(body, "timeZone") } : {}), + }, + "profile_update_failed", + ); + return result instanceof Response + ? result + : jsonResponseFor(request, { ok: true, data: result }); +} diff --git a/src/lib/edge/legacy-archive.ts b/src/lib/edge/legacy-archive.ts new file mode 100644 index 00000000..371fb6a1 --- /dev/null +++ b/src/lib/edge/legacy-archive.ts @@ -0,0 +1,136 @@ +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; +import type { Env } from "@/lib/edge/types"; +import { bad, errorResponse, jsonResponseFor } from "@/lib/response"; + +export async function handleLegacyArchiveManifest( + request: Request, + env: Env, +): Promise { + const incomingUrl = new URL(request.url); + const siteId = incomingUrl.searchParams.get("siteId") || ""; + const from = incomingUrl.searchParams.get("from") || ""; + const to = incomingUrl.searchParams.get("to") || ""; + + if (siteId.length === 0) { + return bad("Missing siteId", "missing_site_id", request); + } + + const privateUrl = new URL("/api/private/archive/manifest", request.url); + privateUrl.searchParams.set("siteId", siteId); + privateUrl.searchParams.set("from", from); + privateUrl.searchParams.set("to", to); + const privateRequest = new Request(privateUrl, { + method: "GET", + headers: request.headers, + }); + const edgeRes = await handlePrivateArchiveManifest( + privateRequest, + env, + privateUrl, + ); + + const text = await edgeRes.text(); + if (!edgeRes.ok) { + return errorResponse( + request, + edgeRes.status, + "fetch_archive_manifest_failed", + text, + ); + } + + let payload: unknown; + try { + payload = JSON.parse(text) as unknown; + } catch { + return errorResponse( + request, + 502, + "invalid_manifest_json", + "Archive manifest payload is invalid JSON", + ); + } + + const files = + payload && + typeof payload === "object" && + "files" in payload && + Array.isArray((payload as { files: unknown }).files) + ? (payload as { files: Array> }).files + : []; + + const normalizedFiles = files.map((file) => ({ + ...file, + fetchUrl: + typeof file.archiveKey === "string" + ? `/api/archive/file?key=${encodeURIComponent(file.archiveKey)}` + : undefined, + })); + + return jsonResponseFor(request, { + ...(payload && typeof payload === "object" ? payload : {}), + files: normalizedFiles, + }); +} + +export async function handleLegacyArchiveFile( + request: Request, + env: Env, +): Promise { + const incomingUrl = new URL(request.url); + const key = incomingUrl.searchParams.get("key") || ""; + if (key.length === 0) { + return bad("Missing key", "missing_key", request); + } + + const privateUrl = new URL("/api/private/archive/file", request.url); + privateUrl.searchParams.set("key", key); + const headers = new Headers(request.headers); + const privateRequest = new Request(privateUrl, { + method: request.method === "HEAD" ? "HEAD" : "GET", + headers, + }); + const edgeRes = await handlePrivateArchiveFile( + privateRequest, + env, + privateUrl, + ); + if (!edgeRes.ok && edgeRes.status !== 206) { + const text = await edgeRes.text(); + return errorResponse( + request, + edgeRes.status, + "fetch_archive_file_failed", + text, + ); + } + + const responseHeaders = new Headers(); + const passthrough = [ + "content-type", + "cache-control", + "accept-ranges", + "content-range", + "content-length", + "etag", + "last-modified", + ]; + for (const name of passthrough) { + const value = edgeRes.headers.get(name); + if (value) { + responseHeaders.set(name, value); + } + } + + if (!responseHeaders.has("content-type")) { + responseHeaders.set("content-type", "application/vnd.apache.parquet"); + } + + return new Response(request.method === "HEAD" ? null : edgeRes.body, { + status: edgeRes.status, + headers: responseHeaders, + }); +} diff --git a/src/lib/edge/legacy-auth.ts b/src/lib/edge/legacy-auth.ts new file mode 100644 index 00000000..c0e21310 --- /dev/null +++ b/src/lib/edge/legacy-auth.ts @@ -0,0 +1,199 @@ +import { SESSION_COOKIE, SESSION_DURATION_SECONDS } from "@/lib/constants"; +import { handleAuthLoginAdmin } from "@/lib/edge/admin-users"; +import type { Env } from "@/lib/edge/types"; +import { + assertContentSize, + BODY_SIZE_LIMITS, + bodyStr, + parseRequestBody, +} from "@/lib/form-helpers"; +import { bad, errorResponse, jsonResponseFor, una } from "@/lib/response"; +import { dashboardSessionSecret } from "@/lib/secrets"; + +interface LoginUser { + id: string; + username: string; + name?: string; + systemRole?: "admin" | "user"; +} + +interface LoginPayload { + ok?: boolean; + data?: { + user?: LoginUser; + }; +} + +function bytes(input: string): Uint8Array { + const encoded = new TextEncoder().encode(input); + const out = new Uint8Array(encoded.length); + out.set(encoded); + return out; +} + +function toArrayBuffer(input: Uint8Array): ArrayBuffer { + const out = new Uint8Array(input.length); + out.set(input); + return out.buffer; +} + +function base64UrlEncode(input: Uint8Array): string { + let binary = ""; + for (let i = 0; i < input.length; i += 1) { + binary += String.fromCharCode(input[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +async function hmacSha256( + message: string, + secret: string, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + toArrayBuffer(bytes(secret)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + toArrayBuffer(bytes(message)), + ); + return new Uint8Array(sig); +} + +async function createSessionTokenForEnv( + env: Env, + claims: { + userId: string; + username: string; + displayName: string; + systemRole: "admin" | "user"; + }, + maxAgeSeconds: number, +): Promise { + const secret = + (await dashboardSessionSecret(env)) || + "insightflare-session-secret-change-me"; + const payload = { + ...claims, + exp: Math.floor(Date.now() / 1000) + maxAgeSeconds, + }; + const encodedPayload = base64UrlEncode(bytes(JSON.stringify(payload))); + const signature = await hmacSha256(encodedPayload, secret); + return `${encodedPayload}.${base64UrlEncode(signature)}`; +} + +export async function handleLegacyAuthLogin( + request: Request, + env: Env, +): Promise { + const sizeError = assertContentSize(request, BODY_SIZE_LIMITS.LOGIN); + if (sizeError) return sizeError; + + const body = await parseRequestBody(request); + const username = bodyStr(body, "username"); + const password = String(body.password ?? ""); + const nextPathRaw = bodyStr(body, "next") || "/app"; + const nextPathClean = nextPathRaw.split("?")[0].replace(/\/+$/, ""); + const isUnsafe = + !nextPathRaw.startsWith("/") || + nextPathRaw.startsWith("//") || + nextPathClean === "/login" || + nextPathClean.endsWith("/login"); + const nextPath = isUnsafe ? "/app" : nextPathRaw; + + if (username.length < 2 || password.length < 1) { + return bad("Invalid credentials", "invalid_credentials", request); + } + + const headers = new Headers(request.headers); + headers.set("content-type", "application/json"); + const adminRequest = new Request(request.url, { + method: "POST", + headers, + body: JSON.stringify({ username, password }), + }); + const adminResponse = await handleAuthLoginAdmin(adminRequest, env); + const text = await adminResponse.text(); + + if (!adminResponse.ok) { + if (adminResponse.status === 401) { + return una("Invalid credentials", "invalid_credentials", request); + } + return errorResponse( + request, + adminResponse.status >= 400 ? adminResponse.status : 502, + "login_upstream_failed", + text, + ); + } + + let payload: LoginPayload; + try { + payload = JSON.parse(text) as LoginPayload; + } catch { + return errorResponse( + request, + 502, + "login_upstream_failed", + "Login response payload is invalid JSON", + ); + } + + const user = payload.data?.user; + if (!payload.ok || !user?.id || !user.username) { + return errorResponse( + request, + 502, + "login_upstream_failed", + "Login response payload is missing user data", + ); + } + + const token = await createSessionTokenForEnv( + env, + { + userId: user.id, + username: user.username, + displayName: user.name || user.username, + systemRole: user.systemRole === "admin" ? "admin" : "user", + }, + SESSION_DURATION_SECONDS, + ); + + const cookieParts = [ + `${SESSION_COOKIE}=${token}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${SESSION_DURATION_SECONDS}`, + ]; + if (process.env.NODE_ENV === "production") { + cookieParts.push("Secure"); + } + + const response = jsonResponseFor(request, { + ok: true, + data: { next: nextPath }, + }); + response.headers.set("set-cookie", cookieParts.join("; ")); + return response; +} + +export function handleLegacyAuthLogout(request: Request): Response { + const response = jsonResponseFor(request, { + ok: true, + data: { next: "/login" }, + }); + response.headers.set( + "set-cookie", + `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, + ); + return response; +} diff --git a/src/lib/edge/map-tiles.ts b/src/lib/edge/map-tiles.ts new file mode 100644 index 00000000..215fdd6d --- /dev/null +++ b/src/lib/edge/map-tiles.ts @@ -0,0 +1,125 @@ +import { requireSameOrigin } from "@/lib/edge/utils"; + +const LIGHT_TILE_UPSTREAMS = [ + "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + "https://basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png", + "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", +] as const; + +const DARK_TILE_UPSTREAMS = [ + "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "https://basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png", +] as const; + +type TileTheme = "light" | "dark"; + +function parseIntStrict(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const next = Number.parseInt(value, 10); + return Number.isFinite(next) ? next : null; +} + +function resolveY(raw: string): number | null { + const normalized = raw.endsWith(".png") ? raw.slice(0, -4) : raw; + return parseIntStrict(normalized); +} + +function validateTileCoordinate(z: number, x: number, y: number): boolean { + if (z < 0 || z > 20) return false; + const max = 2 ** z; + return y >= 0 && y < max && Number.isFinite(x); +} + +function normalizeTileX(x: number, z: number): number { + const max = 2 ** z; + return ((x % max) + max) % max; +} + +function buildUpstreamUrl( + template: string, + z: number, + x: number, + y: number, +): string { + return template + .replace("{z}", String(z)) + .replace("{x}", String(x)) + .replace("{y}", String(y)); +} + +function resolveTileTheme(request: Request): TileTheme { + const url = new URL(request.url); + return url.searchParams.get("theme") === "dark" ? "dark" : "light"; +} + +function resolveTileUpstreams(theme: TileTheme): readonly string[] { + if (theme === "dark") { + return [...DARK_TILE_UPSTREAMS, ...LIGHT_TILE_UPSTREAMS]; + } + return LIGHT_TILE_UPSTREAMS; +} + +export async function handleMapTileRequest( + request: Request, + params: { z: string; x: string; y: string }, +): Promise { + const sameOriginError = requireSameOrigin(request); + if (sameOriginError) return sameOriginError; + + const z = parseIntStrict(params.z); + const x = parseIntStrict(params.x); + const y = resolveY(params.y); + + if ( + z === null || + x === null || + y === null || + !validateTileCoordinate(z, x, y) + ) { + return new Response("Invalid tile coordinate", { status: 400 }); + } + + const normalizedX = normalizeTileX(x, z); + const theme = resolveTileTheme(request); + const upstreams = resolveTileUpstreams(theme); + + let lastStatus = 502; + + for (const template of upstreams) { + const upstreamUrl = buildUpstreamUrl(template, z, normalizedX, y); + try { + const upstreamRes = await fetch(upstreamUrl, { + headers: { + accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + }, + cf: { + cacheEverything: true, + cacheTtl: 60 * 60 * 24 * 30, + }, + }); + + if (!upstreamRes.ok) { + lastStatus = upstreamRes.status; + continue; + } + + const body = await upstreamRes.arrayBuffer(); + return new Response(body, { + status: 200, + headers: { + "content-type": + upstreamRes.headers.get("content-type") || "image/png", + "cache-control": + "public, max-age=2592000, s-maxage=2592000, stale-while-revalidate=2592000", + "access-control-allow-origin": "*", + vary: "Accept", + "x-map-theme": theme, + }, + }); + } catch { + lastStatus = 502; + } + } + + return new Response("Tile upstream unavailable", { status: lastStatus }); +} diff --git a/src/lib/edge/query/core-dimensions.ts b/src/lib/edge/query/core-dimensions.ts index 042fc2a7..1616690c 100644 --- a/src/lib/edge/query/core-dimensions.ts +++ b/src/lib/edge/query/core-dimensions.ts @@ -1,3 +1,5 @@ +import { browserEngineCaseSql } from "@/lib/browser-engine"; + import { type ClientDimensionKey, DIRECT_REFERRER_FILTER_VALUE, @@ -154,3 +156,104 @@ export function regionValueExpr(): string { export function cityValueExpr(): string { return "CASE WHEN TRIM(country) = '' AND TRIM(region_code) = '' AND TRIM(region) = '' AND TRIM(city) = '' THEN '' ELSE TRIM(country) || '::' || CASE WHEN TRIM(region_code) != '' THEN TRIM(region_code) ELSE TRIM(region) END || '::' || TRIM(region) || '::' || TRIM(city) END"; } + +export function resolveCrossBreakdownDimension( + dimension: string, +): { labelExpr: string; fallbackKeyBase: string } | null { + // ── page ────────────────────────────────────────────────────────────── + if (dimension === "page.path") + return { + labelExpr: "TRIM(COALESCE(pathname, ''))", + fallbackKeyBase: "page", + }; + if (dimension === "page.title") + return { labelExpr: "TRIM(COALESCE(title, ''))", fallbackKeyBase: "title" }; + if (dimension === "page.hostname") + return { + labelExpr: "TRIM(COALESCE(hostname, ''))", + fallbackKeyBase: "hostname", + }; + if (dimension === "page.query") + return { + labelExpr: "TRIM(COALESCE(query_string, ''))", + fallbackKeyBase: "query", + }; + if (dimension === "page.hash") + return { + labelExpr: "TRIM(COALESCE(hash_fragment, ''))", + fallbackKeyBase: "hash", + }; + + // ── session (requires session-level aggregation, not supported) ─────── + if (dimension === "session.entryPath" || dimension === "session.exitPath") + return null; + + // ── referrer ────────────────────────────────────────────────────────── + if (dimension === "referrer.domain") + return referrerDomainDimensionDefinition(); + if (dimension === "referrer.url") + return { + labelExpr: "TRIM(COALESCE(referrer_url, ''))", + fallbackKeyBase: "referrer-url", + }; + + // ── utm ─────────────────────────────────────────────────────────────── + if (dimension === "utm.source") return utmDimensionDefinition("source"); + if (dimension === "utm.medium") return utmDimensionDefinition("medium"); + if (dimension === "utm.campaign") return utmDimensionDefinition("campaign"); + if (dimension === "utm.term") return utmDimensionDefinition("term"); + if (dimension === "utm.content") return utmDimensionDefinition("content"); + + // ── client ──────────────────────────────────────────────────────────── + if (dimension === "client.browser") + return clientDimensionDefinition("browser"); + if (dimension === "client.browserVersion") + return { + labelExpr: "TRIM(COALESCE(browser_version, ''))", + fallbackKeyBase: "browser-version", + }; + if (dimension === "client.browserEngine") + return { + labelExpr: browserEngineCaseSql("browser", "os"), + fallbackKeyBase: "engine", + }; + if (dimension === "client.os") + return clientDimensionDefinition("operatingSystem"); + if (dimension === "client.osVersion") + return clientDimensionDefinition("osVersion"); + if (dimension === "client.deviceType") + return clientDimensionDefinition("deviceType"); + if (dimension === "client.language") + return clientDimensionDefinition("language"); + if (dimension === "client.screenSize") + return clientDimensionDefinition("screenSize"); + + // ── geo ─────────────────────────────────────────────────────────────── + if (dimension === "geo.country") + return { + labelExpr: "TRIM(COALESCE(country, ''))", + fallbackKeyBase: "country", + }; + if (dimension === "geo.region") + return { labelExpr: regionValueExpr(), fallbackKeyBase: "region" }; + if (dimension === "geo.city") + return { labelExpr: cityValueExpr(), fallbackKeyBase: "city" }; + if (dimension === "geo.continent") + return { + labelExpr: "TRIM(COALESCE(continent, ''))", + fallbackKeyBase: "continent", + }; + if (dimension === "geo.timeZone") + return { + labelExpr: "TRIM(COALESCE(timezone, ''))", + fallbackKeyBase: "timezone", + }; + if (dimension === "geo.organization") + return { + labelExpr: "TRIM(COALESCE(as_organization, ''))", + fallbackKeyBase: "organization", + }; + + // ── event (requires events table join, not supported) ───────────────── + return null; +} diff --git a/src/lib/edge/query/core-types.ts b/src/lib/edge/query/core-types.ts index e19d9400..9131ebbc 100644 --- a/src/lib/edge/query/core-types.ts +++ b/src/lib/edge/query/core-types.ts @@ -3,7 +3,7 @@ export const PRIVATE_CACHE_HEADERS = { vary: "authorization, cookie", }; export const PUBLIC_CACHE_HEADERS = { - "cache-control": "public, max-age=60, s-maxage=60", + "cache-control": "public, max-age=300, s-maxage=300", "access-control-allow-origin": "*", }; export const PUBLIC_PRIVACY = { @@ -523,6 +523,7 @@ export interface GeoPointRow { region: string; regionCode: string; city: string; + pointCount: number; } export interface GeoCountryCountRow { diff --git a/src/lib/edge/query/entry.ts b/src/lib/edge/query/entry.ts index bbc1a2c0..1cd61fb2 100644 --- a/src/lib/edge/query/entry.ts +++ b/src/lib/edge/query/entry.ts @@ -1,10 +1,17 @@ -import { withDashboardCache } from "@/lib/edge/dashboard-cache"; +import { + PUBLIC_QUERY_CACHE_OPTIONS, + withDashboardCache, +} from "@/lib/edge/dashboard-cache"; import type { Env } from "@/lib/edge/types"; +import { jsonResponse } from "./core"; import { fetchPublicSite, notAllowed, resolvePrivateSite } from "./core"; import { routeQuery } from "./router"; import { handleTeamDashboard } from "./team"; +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handlePrivateQuery( request: Request, env: Env, @@ -44,6 +51,9 @@ export async function handlePrivateQuery( ); } +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handlePublicQuery( request: Request, env: Env, @@ -55,7 +65,28 @@ export async function handlePublicQuery( if (site instanceof Response) return site; const segments = url.pathname.split("/").filter(Boolean); const pathname = segments.slice(3).join("/"); - return withDashboardCache(ctx, url, () => - routeQuery(env, site.id, pathname, url, { publicMode: true }, request), + if (pathname === "site") { + return withDashboardCache( + ctx, + url, + async () => + jsonResponse({ + ok: true, + data: { + slug: decodeURIComponent(segments[2] || ""), + name: site.name, + domain: site.domain, + id: site.id, + }, + }), + PUBLIC_QUERY_CACHE_OPTIONS, + ); + } + return withDashboardCache( + ctx, + url, + () => + routeQuery(env, site.id, pathname, url, { publicMode: true }, request), + PUBLIC_QUERY_CACHE_OPTIONS, ); } diff --git a/src/lib/edge/query/journey-geo-queries.ts b/src/lib/edge/query/journey-geo-queries.ts index 8d72e4a2..2299705d 100644 --- a/src/lib/edge/query/journey-geo-queries.ts +++ b/src/lib/edge/query/journey-geo-queries.ts @@ -67,28 +67,39 @@ export async function queryGeoPointsFromD1( ): Promise { const filter = buildVisitFilterSql(filters); const parsedGeo = parseGeoFilterValue(filters.geo); + const coordinateClause = filter.clause ? "AND" : "WHERE"; const pointsSql = ` WITH ${buildVisitSourceCte()}, filtered_visits AS ( - SELECT * + SELECT + ROUND(latitude, 3) AS lat_bucket, + ROUND(longitude, 3) AS lon_bucket, + country, + region, + region_code AS regionCode, + city, + MAX(started_at) AS latest_at, + COUNT(*) AS point_count FROM visit_source ${filter.clause} + ${coordinateClause} + latitude IS NOT NULL + AND longitude IS NOT NULL + AND ABS(latitude) <= 90 + AND ABS(longitude) <= 180 + GROUP BY lat_bucket, lon_bucket, country, region, region_code, city ) SELECT - latitude, - longitude, - started_at AS timestampMs, + lat_bucket AS latitude, + lon_bucket AS longitude, + latest_at AS timestampMs, country, region, - region_code AS regionCode, - city + regionCode, + city, + point_count AS pointCount FROM filtered_visits -WHERE - latitude IS NOT NULL - AND longitude IS NOT NULL - AND ABS(latitude) <= 90 - AND ABS(longitude) <= 180 ORDER BY timestampMs DESC LIMIT ? `; diff --git a/src/lib/edge/query/journey-helpers.ts b/src/lib/edge/query/journey-helpers.ts index 9cf0fe8a..db97301f 100644 --- a/src/lib/edge/query/journey-helpers.ts +++ b/src/lib/edge/query/journey-helpers.ts @@ -208,6 +208,7 @@ export function mapGeoPointRow(row: Record): GeoPointRow { region: String(row.region ?? ""), regionCode: String(row.regionCode ?? ""), city: String(row.city ?? ""), + pointCount: Math.max(1, Number(row.pointCount ?? 1)), }; } diff --git a/src/lib/edge/query/router.ts b/src/lib/edge/query/router.ts index bc68e325..941e1c9b 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -46,239 +46,309 @@ import { handleBrowserRadar, handleBrowserTrend, handleBrowserVersionBreakdown, - handleClientCrossBreakdown, handleClientDimensionTrend, + handleCrossBreakdown, handleReferrerDimensionTrend, handleReferrerRadar, handleUtmDimensionTrend, } from "./technology"; -export async function routeQuery( - env: Env, - siteId: string, - pathname: string, - url: URL, - options: { publicMode: boolean }, - request?: Request, -): Promise { - const ctx: ResponseContext | undefined = request - ? { requestId: getRequestId(request) } - : undefined; +export const PUBLIC_QUERY_PATHS = [ + "overview", + "trend", + "pages", + "pages-dashboard", + "referrers", + "retention", + "performance", + "countries", + "filter-options", + "event-types", + "page-hash", + "page-query", + "overview-page-path", + "overview-page-title", + "overview-page-hostname", + "overview-page-entry", + "overview-page-exit", + "overview-source-domain", + "overview-source-link", + "overview-client-browser", + "overview-client-os-version", + "overview-client-device-type", + "overview-client-language", + "overview-client-screen-size", + "overview-geo-country", + "overview-geo-region", + "overview-geo-city", + "overview-geo-continent", + "overview-geo-timezone", + "overview-geo-organization", + "overview-geo-points", + "browser-trend", + "browser-engine-trend", + "browser-version-breakdown", + "browser-cross-breakdown", + "browser-radar", + "referrer-radar", + "referrer-dimension-trend", + "client-dimension-trend", + "client-cross-breakdown", + "utm-dimension-trend", + "utm-source", + "utm-medium", + "utm-campaign", + "utm-term", + "utm-content", +] as const; - if (pathname === "overview") return handleOverview(env, siteId, url, ctx); - if (pathname === "trend") return handleTrend(env, siteId, url, ctx); - if (pathname === "pages") { - return handlePages(env, siteId, url, !options.publicMode, ctx); - } - if (pathname === "referrers") { - return handleReferrers( +export const DASHBOARD_QUERY_PATHS = [ + ...PUBLIC_QUERY_PATHS, + "events-summary", + "events-trend", + "events-records", + "event-type-field-values", + "event-type-detail", + "event-record-detail", + "sessions", + "session-detail", + "visitor-detail", + "visitors", + "funnels", + "team-dashboard", +] as const; + +const PUBLIC_QUERY_PATH_SET = new Set(PUBLIC_QUERY_PATHS); + +export interface QueryRouteContext { + env: Env; + siteId: string; + url: URL; + options: { publicMode: boolean }; + request?: Request; + responseContext?: ResponseContext; +} + +export type QueryRouteHandler = ( + context: QueryRouteContext, +) => Promise; + +export const QUERY_ROUTE_HANDLERS: Record = { + overview: ({ env, siteId, url, responseContext }) => + handleOverview(env, siteId, url, responseContext), + trend: ({ env, siteId, url, responseContext }) => + handleTrend(env, siteId, url, responseContext), + pages: ({ env, siteId, url, options, responseContext }) => + handlePages(env, siteId, url, !options.publicMode, responseContext), + referrers: ({ env, siteId, url, options, responseContext }) => + handleReferrers( env, siteId, url, options.publicMode ? 8 : 20, !options.publicMode, - ctx, - ); - } - if (options.publicMode) return notFound(); - if (pathname === "funnels") { - return handleFunnel(env, siteId, url, ctx, request as Request); - } - if (pathname === "pages-dashboard") { - return handlePagesDashboard(env, siteId, url, ctx); - } - if (pathname === "page-hash") { - return handleDimension(env, siteId, url, "hash_fragment", undefined, ctx); - } - if (pathname === "page-query") { - return handleDimension(env, siteId, url, "query_string", undefined, ctx); - } - if (pathname === "event-types") { - return handleEventTypes(env, siteId, url, ctx); - } - if (pathname === "events-summary") { - return handleEventsSummary(env, siteId, url, ctx); - } - if (pathname === "events-trend") { - return handleEventsTrend(env, siteId, url, ctx); - } - if (pathname === "events-records") { - return handleEventsRecords(env, siteId, url, ctx); - } - if (pathname === "event-type-field-values") { - return handleEventTypeFieldValues(env, siteId, url, ctx); - } - if (pathname === "event-type-detail") { - return handleEventTypeDetail(env, siteId, url, ctx); - } - if (pathname === "event-record-detail") { - return handleEventRecordDetail(env, siteId, url, ctx); - } - if (pathname === "sessions") { - return handleSessions(env, siteId, url, ctx); - } - if (pathname === "session-detail") { - return handleSessionDetail(env, siteId, url, ctx); - } - if (pathname === "visitor-detail") { - return handleVisitorDetail(env, siteId, url, ctx); - } - if (pathname === "visitors") { - return handleVisitors(env, siteId, url, ctx); - } - if (pathname === "retention") { - return handleRetention(env, siteId, url, ctx); - } - if (pathname === "performance") { - return handlePerformance(env, siteId, url, ctx); - } - if (pathname === "browser-trend") - return handleBrowserTrend(env, siteId, url, ctx); - if (pathname === "browser-engine-trend") { - return handleBrowserEngineTrend(env, siteId, url, ctx); - } - if (pathname === "browser-version-breakdown") { - return handleBrowserVersionBreakdown(env, siteId, url, ctx); - } - if (pathname === "browser-cross-breakdown") { - return handleBrowserCrossBreakdown(env, siteId, url, ctx); - } - if (pathname === "browser-radar") { - return handleBrowserRadar(env, siteId, url, ctx); - } - if (pathname === "referrer-radar") { - return handleReferrerRadar(env, siteId, url, ctx); - } - if (pathname === "referrer-dimension-trend") { - return handleReferrerDimensionTrend(env, siteId, url, ctx); - } - if (pathname === "client-dimension-trend") { - return handleClientDimensionTrend(env, siteId, url, ctx); - } - if (pathname === "utm-dimension-trend") { - return handleUtmDimensionTrend(env, siteId, url, ctx); - } - if (pathname === "client-cross-breakdown") { - return handleClientCrossBreakdown(env, siteId, url, ctx); - } - if (pathname === "utm-source") { - return handleDimension( + responseContext, + ), + funnels: ({ env, siteId, url, request, responseContext }) => + handleFunnel(env, siteId, url, responseContext, request as Request), + "pages-dashboard": ({ env, siteId, url, responseContext }) => + handlePagesDashboard(env, siteId, url, responseContext), + "page-hash": ({ env, siteId, url, responseContext }) => + handleDimension( + env, + siteId, + url, + "hash_fragment", + undefined, + responseContext, + ), + "page-query": ({ env, siteId, url, responseContext }) => + handleDimension( + env, + siteId, + url, + "query_string", + undefined, + responseContext, + ), + "event-types": ({ env, siteId, url, responseContext }) => + handleEventTypes(env, siteId, url, responseContext), + "events-summary": ({ env, siteId, url, responseContext }) => + handleEventsSummary(env, siteId, url, responseContext), + "events-trend": ({ env, siteId, url, responseContext }) => + handleEventsTrend(env, siteId, url, responseContext), + "events-records": ({ env, siteId, url, responseContext }) => + handleEventsRecords(env, siteId, url, responseContext), + "event-type-field-values": ({ env, siteId, url, responseContext }) => + handleEventTypeFieldValues(env, siteId, url, responseContext), + "event-type-detail": ({ env, siteId, url, responseContext }) => + handleEventTypeDetail(env, siteId, url, responseContext), + "event-record-detail": ({ env, siteId, url, responseContext }) => + handleEventRecordDetail(env, siteId, url, responseContext), + sessions: ({ env, siteId, url, responseContext }) => + handleSessions(env, siteId, url, responseContext), + "session-detail": ({ env, siteId, url, responseContext }) => + handleSessionDetail(env, siteId, url, responseContext), + "visitor-detail": ({ env, siteId, url, responseContext }) => + handleVisitorDetail(env, siteId, url, responseContext), + visitors: ({ env, siteId, url, responseContext }) => + handleVisitors(env, siteId, url, responseContext), + retention: ({ env, siteId, url, responseContext }) => + handleRetention(env, siteId, url, responseContext), + performance: ({ env, siteId, url, responseContext }) => + handlePerformance(env, siteId, url, responseContext), + "browser-trend": ({ env, siteId, url, responseContext }) => + handleBrowserTrend(env, siteId, url, responseContext), + "browser-engine-trend": ({ env, siteId, url, responseContext }) => + handleBrowserEngineTrend(env, siteId, url, responseContext), + "browser-version-breakdown": ({ env, siteId, url, responseContext }) => + handleBrowserVersionBreakdown(env, siteId, url, responseContext), + "browser-cross-breakdown": ({ env, siteId, url, responseContext }) => + handleBrowserCrossBreakdown(env, siteId, url, responseContext), + "browser-radar": ({ env, siteId, url, responseContext }) => + handleBrowserRadar(env, siteId, url, responseContext), + "referrer-radar": ({ env, siteId, url, responseContext }) => + handleReferrerRadar(env, siteId, url, responseContext), + "referrer-dimension-trend": ({ env, siteId, url, responseContext }) => + handleReferrerDimensionTrend(env, siteId, url, responseContext), + "client-dimension-trend": ({ env, siteId, url, responseContext }) => + handleClientDimensionTrend(env, siteId, url, responseContext), + "utm-dimension-trend": ({ env, siteId, url, responseContext }) => + handleUtmDimensionTrend(env, siteId, url, responseContext), + "client-cross-breakdown": ({ env, siteId, url, responseContext }) => + handleCrossBreakdown(env, siteId, url, responseContext), + "utm-source": ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, utmDimensionDefinition("source").labelExpr, undefined, - ctx, - ); - } - if (pathname === "utm-medium") { - return handleDimension( + responseContext, + ), + "utm-medium": ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, utmDimensionDefinition("medium").labelExpr, undefined, - ctx, - ); - } - if (pathname === "utm-campaign") { - return handleDimension( + responseContext, + ), + "utm-campaign": ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, utmDimensionDefinition("campaign").labelExpr, undefined, - ctx, - ); - } - if (pathname === "utm-term") { - return handleDimension( + responseContext, + ), + "utm-term": ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, utmDimensionDefinition("term").labelExpr, undefined, - ctx, - ); - } - if (pathname === "utm-content") { - return handleDimension( + responseContext, + ), + "utm-content": ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, utmDimensionDefinition("content").labelExpr, undefined, - ctx, - ); - } - if (pathname === "countries") { - return handleDimension( + responseContext, + ), + countries: ({ env, siteId, url, responseContext }) => + handleDimension( env, siteId, url, "country", { ignoreGeo: true }, - ctx, - ); - } - if (pathname === "filter-options") - return handleFilterOptions(env, siteId, url, ctx); - if (pathname === "overview-page-path") { - return handleOverviewPageTab(env, siteId, url, "path", ctx); - } - if (pathname === "overview-page-title") { - return handleOverviewPageTab(env, siteId, url, "title", ctx); - } - if (pathname === "overview-page-hostname") { - return handleOverviewPageTab(env, siteId, url, "hostname", ctx); - } - if (pathname === "overview-page-entry") { - return handleOverviewPageTab(env, siteId, url, "entry", ctx); - } - if (pathname === "overview-page-exit") { - return handleOverviewPageTab(env, siteId, url, "exit", ctx); - } - if (pathname === "overview-source-domain") { - return handleOverviewSourceTab(env, siteId, url, "domain", ctx); - } - if (pathname === "overview-source-link") { - return handleOverviewSourceTab(env, siteId, url, "link", ctx); - } - if (pathname === "overview-client-browser") { - return handleOverviewClientTab(env, siteId, url, "browser", ctx); - } - if (pathname === "overview-client-os-version") { - return handleOverviewClientTab(env, siteId, url, "osVersion", ctx); - } - if (pathname === "overview-client-device-type") { - return handleOverviewClientTab(env, siteId, url, "deviceType", ctx); - } - if (pathname === "overview-client-language") { - return handleOverviewClientTab(env, siteId, url, "language", ctx); - } - if (pathname === "overview-client-screen-size") { - return handleOverviewClientTab(env, siteId, url, "screenSize", ctx); - } - if (pathname === "overview-geo-country") { - return handleOverviewGeoTab(env, siteId, url, "country", ctx); - } - if (pathname === "overview-geo-region") { - return handleOverviewGeoTab(env, siteId, url, "region", ctx); - } - if (pathname === "overview-geo-city") { - return handleOverviewGeoTab(env, siteId, url, "city", ctx); - } - if (pathname === "overview-geo-continent") { - return handleOverviewGeoTab(env, siteId, url, "continent", ctx); - } - if (pathname === "overview-geo-timezone") { - return handleOverviewGeoTab(env, siteId, url, "timezone", ctx); - } - if (pathname === "overview-geo-organization") { - return handleOverviewGeoTab(env, siteId, url, "organization", ctx); - } - if (pathname === "overview-geo-points") { - return handleOverviewGeoPoints(env, siteId, url, ctx); + responseContext, + ), + "filter-options": ({ env, siteId, url, responseContext }) => + handleFilterOptions(env, siteId, url, responseContext), + "overview-page-path": ({ env, siteId, url, responseContext }) => + handleOverviewPageTab(env, siteId, url, "path", responseContext), + "overview-page-title": ({ env, siteId, url, responseContext }) => + handleOverviewPageTab(env, siteId, url, "title", responseContext), + "overview-page-hostname": ({ env, siteId, url, responseContext }) => + handleOverviewPageTab(env, siteId, url, "hostname", responseContext), + "overview-page-entry": ({ env, siteId, url, responseContext }) => + handleOverviewPageTab(env, siteId, url, "entry", responseContext), + "overview-page-exit": ({ env, siteId, url, responseContext }) => + handleOverviewPageTab(env, siteId, url, "exit", responseContext), + "overview-source-domain": ({ env, siteId, url, responseContext }) => + handleOverviewSourceTab(env, siteId, url, "domain", responseContext), + "overview-source-link": ({ env, siteId, url, responseContext }) => + handleOverviewSourceTab(env, siteId, url, "link", responseContext), + "overview-client-browser": ({ env, siteId, url, responseContext }) => + handleOverviewClientTab(env, siteId, url, "browser", responseContext), + "overview-client-os-version": ({ env, siteId, url, responseContext }) => + handleOverviewClientTab(env, siteId, url, "osVersion", responseContext), + "overview-client-device-type": ({ env, siteId, url, responseContext }) => + handleOverviewClientTab(env, siteId, url, "deviceType", responseContext), + "overview-client-language": ({ env, siteId, url, responseContext }) => + handleOverviewClientTab(env, siteId, url, "language", responseContext), + "overview-client-screen-size": ({ env, siteId, url, responseContext }) => + handleOverviewClientTab(env, siteId, url, "screenSize", responseContext), + "overview-geo-country": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "country", responseContext), + "overview-geo-region": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "region", responseContext), + "overview-geo-city": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "city", responseContext), + "overview-geo-continent": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "continent", responseContext), + "overview-geo-timezone": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "timezone", responseContext), + "overview-geo-organization": ({ env, siteId, url, responseContext }) => + handleOverviewGeoTab(env, siteId, url, "organization", responseContext), + "overview-geo-points": ({ env, siteId, url, responseContext }) => + handleOverviewGeoPoints(env, siteId, url, responseContext), +}; + +export function queryRouteHandler( + pathname: string, + options: { publicMode: boolean }, +): QueryRouteHandler | null { + if (options.publicMode && !PUBLIC_QUERY_PATH_SET.has(pathname)) { + return null; } - return notFound(); + return QUERY_ROUTE_HANDLERS[pathname] ?? null; +} + +export async function dispatchQueryRoute( + env: Env, + siteId: string, + pathname: string, + url: URL, + options: { publicMode: boolean }, + request?: Request, +): Promise { + const responseContext: ResponseContext | undefined = request + ? { requestId: getRequestId(request) } + : undefined; + const handler = queryRouteHandler(pathname, options); + if (!handler) return notFound(); + return handler({ env, siteId, url, options, request, responseContext }); +} + +/** + * Compatibility wrapper. Production Hono routing calls dispatchQueryRoute. + */ +export async function routeQuery( + env: Env, + siteId: string, + pathname: string, + url: URL, + options: { publicMode: boolean }, + request?: Request, +): Promise { + return dispatchQueryRoute(env, siteId, pathname, url, options, request); } diff --git a/src/lib/edge/query/technology/client-cross.ts b/src/lib/edge/query/technology/client-cross.ts index 54af8b6f..16e8d81e 100644 --- a/src/lib/edge/query/technology/client-cross.ts +++ b/src/lib/edge/query/technology/client-cross.ts @@ -3,7 +3,6 @@ import type { BrowserCrossBreakdownDimensionRow, BrowserCrossBreakdownItemRow, ClientCrossAggregateRow, - ClientDimensionKey, DashboardFilters, QueryWindow, } from "@/lib/edge/query/core"; @@ -13,7 +12,6 @@ import { CLIENT_CROSS_OTHER_PRIMARY_TOKEN, CLIENT_CROSS_OTHER_SECONDARY_TOKEN, CLIENT_CROSS_UNKNOWN_TOKEN, - clientDimensionDefinition, queryD1All, SHARE_TREND_OTHER_LABEL, shareTrendSeriesKey, @@ -21,23 +19,26 @@ import { } from "@/lib/edge/query/core"; import type { Env } from "@/lib/edge/types"; -export async function queryClientCrossDimensionFromD1( +interface DimensionDefinition { + labelExpr: string; + fallbackKeyBase: string; +} + +export async function queryCrossDimensionFromD1( env: Env, siteId: string, window: QueryWindow, filters: DashboardFilters, primaryLimit: number, secondaryLimit: number, - primaryDimension: ClientDimensionKey, - secondaryDimension: ClientDimensionKey, + primaryDimension: DimensionDefinition, + secondaryDimension: DimensionDefinition, ): Promise { const filter = buildVisitFilterSql(filters); const normalizedPrimaryLimit = Math.min(Math.max(1, primaryLimit), 12); const normalizedSecondaryLimit = Math.min(Math.max(1, secondaryLimit), 8); - const primaryDefinition = clientDimensionDefinition(primaryDimension); - const secondaryDefinition = clientDimensionDefinition(secondaryDimension); - const primaryExpr = primaryDefinition.labelExpr; - const normalizedSecondaryExpr = `CASE WHEN ${secondaryDefinition.labelExpr} != '' THEN ${secondaryDefinition.labelExpr} ELSE '${CLIENT_CROSS_UNKNOWN_TOKEN}' END`; + const primaryExpr = primaryDimension.labelExpr; + const normalizedSecondaryExpr = `CASE WHEN ${secondaryDimension.labelExpr} != '' THEN ${secondaryDimension.labelExpr} ELSE '${CLIENT_CROSS_UNKNOWN_TOKEN}' END`; const topPrimarySql = ` WITH @@ -269,7 +270,7 @@ ORDER BY primaryValue ASC, secondaryValue ASC key: shareTrendSeriesKey( row.value, columnKeySet, - secondaryDefinition.fallbackKeyBase, + secondaryDimension.fallbackKeyBase, ), label: row.value, views: row.views, @@ -310,7 +311,7 @@ ORDER BY primaryValue ASC, secondaryValue ASC key: shareTrendSeriesKey( row.value, rowKeySet, - primaryDefinition.fallbackKeyBase, + primaryDimension.fallbackKeyBase, ), label: row.value, views: row.views, diff --git a/src/lib/edge/query/technology/handlers.ts b/src/lib/edge/query/technology/handlers.ts index b5b2720f..c3b95e8d 100644 --- a/src/lib/edge/query/technology/handlers.ts +++ b/src/lib/edge/query/technology/handlers.ts @@ -6,6 +6,7 @@ import { parseLimit, parseQueryLimit, parseWindow, + resolveCrossBreakdownDimension, type ResponseContext, } from "@/lib/edge/query/core"; import type { Env } from "@/lib/edge/types"; @@ -17,7 +18,7 @@ import { queryBrowserTrendFromD1, queryBrowserVersionBreakdownFromD1, } from "./browser"; -import { queryClientCrossDimensionFromD1 } from "./client-cross"; +import { queryCrossDimensionFromD1 } from "./client-cross"; import { parseClientDimensionKey, parseUtmDimensionKey } from "./parsers"; import { queryBrowserRadarFromD1, queryReferrerRadarFromD1 } from "./radar"; import { @@ -301,21 +302,19 @@ export async function handleReferrerDimensionTrend( }); } -export async function handleClientCrossBreakdown( +export async function handleCrossBreakdown( env: Env, siteId: string, url: URL, ctx?: ResponseContext, ): Promise { - const primaryDimension = parseClientDimensionKey( - url.searchParams.get("primaryDimension"), - ); - if (!primaryDimension) return badRequest("Invalid primary dimension"); - const secondaryDimension = parseClientDimensionKey( - url.searchParams.get("secondaryDimension"), - ); - if (!secondaryDimension) return badRequest("Invalid secondary dimension"); - if (primaryDimension === secondaryDimension) { + const primaryRaw = url.searchParams.get("primaryDimension") || ""; + const secondaryRaw = url.searchParams.get("secondaryDimension") || ""; + const primaryDimension = resolveCrossBreakdownDimension(primaryRaw); + if (!primaryDimension) return badRequest("Unsupported primary dimension"); + const secondaryDimension = resolveCrossBreakdownDimension(secondaryRaw); + if (!secondaryDimension) return badRequest("Unsupported secondary dimension"); + if (primaryRaw === secondaryRaw) { return badRequest("Primary and secondary dimensions must differ"); } const window = parseWindow(url); @@ -323,7 +322,7 @@ export async function handleClientCrossBreakdown( const filters = parseFilters(url); const primaryLimit = parseQueryLimit(url, "primaryLimit", 5, 1, 12); const secondaryLimit = parseQueryLimit(url, "secondaryLimit", 6, 1, 8); - const data = await queryClientCrossDimensionFromD1( + const data = await queryCrossDimensionFromD1( env, siteId, window, diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts new file mode 100644 index 00000000..c8c5ab7e --- /dev/null +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -0,0 +1,337 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { handleUsersAdmin } from "@/lib/edge/admin-users"; +import { handleAdminWs } from "@/lib/edge/admin-ws"; +import { authenticateApiKey } from "@/lib/edge/api-key-auth"; +import { handleApiV1, handleCapabilities } from "@/lib/edge/api-v1"; +import { + handlePrivateArchive, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; +import { + handleCollectOptionsRequest, + handleCollectRequest, +} from "@/lib/edge/collect"; +import { handleLegacyAdminUser } from "@/lib/edge/legacy-admin"; +import { handleLegacyArchiveFile } from "@/lib/edge/legacy-archive"; +import { handleLegacyAuthLogin } from "@/lib/edge/legacy-auth"; +import { handleMapTileRequest } from "@/lib/edge/map-tiles"; +import { handlePrivateQuery, handlePublicQuery } from "@/lib/edge/query"; +import type * as QueryCoreModule from "@/lib/edge/query/core"; +import { fetchPublicSite, resolvePrivateSite } from "@/lib/edge/query/core"; +import type * as QueryRouterModule from "@/lib/edge/query/router"; +import { dispatchQueryRoute } from "@/lib/edge/query/router"; +import { handleTrackerScriptRequest } from "@/lib/edge/script-endpoint"; +import apiApp from "@/lib/hono/app"; + +vi.mock("@/lib/edge/admin", () => ({ + handlePrivateAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-ws", () => ({ + handleAdminWs: vi.fn(), +})); + +vi.mock("@/lib/edge/archive-query", () => ({ + handlePrivateArchiveFile: vi.fn(), + handlePrivateArchive: vi.fn(), + handlePrivateArchiveManifest: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-users", () => ({ + handleAuthLoginAdmin: vi.fn(), + handleAuthMeAdmin: vi.fn(), + handleProfileAdmin: vi.fn(), + handleUsersAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/collect", () => ({ + handleCollectOptionsRequest: vi.fn(), + handleCollectRequest: vi.fn(), +})); + +vi.mock("@/lib/edge/legacy-admin", () => ({ + handleLegacyAdminUser: vi.fn(), +})); + +vi.mock("@/lib/edge/legacy-archive", () => ({ + handleLegacyArchiveFile: vi.fn(), + handleLegacyArchiveManifest: vi.fn(), +})); + +vi.mock("@/lib/edge/legacy-auth", () => ({ + handleLegacyAuthLogin: vi.fn(), + handleLegacyAuthLogout: vi.fn(), +})); + +vi.mock("@/lib/edge/map-tiles", () => ({ + handleMapTileRequest: vi.fn(), +})); + +vi.mock("@/lib/edge/query", () => ({ + handlePrivateQuery: vi.fn(), + handlePublicQuery: vi.fn(), +})); + +vi.mock("@/lib/edge/query/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchPublicSite: vi.fn(), + resolvePrivateSite: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/query/router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchQueryRoute: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/api-v1", () => ({ + apiV1Segments: (url: URL) => + url.pathname + .replace(/^\/api\/v1\/?/, "") + .split("/") + .filter(Boolean), + handleAnalytics: vi.fn(), + handleApiV1: vi.fn(), + handleBatch: vi.fn(), + handleCapabilities: vi.fn(), + handleEvents: vi.fn(), + handleFunnels: vi.fn(), + handleJourneys: vi.fn(), + handlePerformance: vi.fn(), + handlePrivacy: vi.fn(), + handleRealtime: vi.fn(), + handleRoot: vi.fn(), + handleSharing: vi.fn(), + handleSiteResource: vi.fn(), + handleSitesCollection: vi.fn(), + handleTeam: vi.fn(), + handleToken: vi.fn(), + handleTokenCheck: vi.fn(), + handleTracking: vi.fn(), + handleTrackingScript: vi.fn(), +})); + +vi.mock("@/lib/edge/api-key-auth", () => ({ + authenticateApiKey: vi.fn(), +})); + +vi.mock("@/lib/edge/script-endpoint", () => ({ + handleTrackerScriptRequest: vi.fn(), +})); + +const env = { DB: {}, INGEST_DO: {}, ARCHIVE_BUCKET: {} }; +const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; +const executionCtx = ctx as unknown as ExecutionContext; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +describe("Hono API app routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(handleCollectOptionsRequest).mockResolvedValue( + new Response(null, { status: 204 }), + ); + vi.mocked(handleCollectRequest).mockResolvedValue( + new Response(null, { status: 204 }), + ); + vi.mocked(handleTrackerScriptRequest).mockResolvedValue( + new Response("script"), + ); + vi.mocked(handlePrivateAdmin).mockResolvedValue(new Response("admin")); + vi.mocked(handleUsersAdmin).mockResolvedValue(new Response("admin")); + vi.mocked(handlePrivateArchive).mockResolvedValue(new Response("archive")); + vi.mocked(handlePrivateArchiveManifest).mockResolvedValue( + new Response("archive"), + ); + vi.mocked(handlePrivateQuery).mockResolvedValue( + new Response("private-query"), + ); + vi.mocked(resolvePrivateSite).mockResolvedValue({ + id: "site-1", + name: "Site", + domain: "app.test", + }); + vi.mocked(fetchPublicSite).mockResolvedValue({ + id: "public-site", + name: "Public Site", + domain: "public.test", + }); + vi.mocked(dispatchQueryRoute).mockResolvedValue( + new Response("private-query"), + ); + vi.mocked(handlePublicQuery).mockResolvedValue( + new Response("public-query"), + ); + vi.mocked(handleApiV1).mockResolvedValue(new Response("v1")); + vi.mocked(authenticateApiKey).mockResolvedValue({ + keyId: "key-1", + teamId: "team-1", + prefix: "if_123", + scopes: ["analytics:read"], + siteIds: ["site-1"], + }); + vi.mocked(handleCapabilities).mockResolvedValue(new Response("v1")); + vi.mocked(handleLegacyAuthLogin).mockResolvedValue( + new Response("legacy-login"), + ); + vi.mocked(handleLegacyAdminUser).mockResolvedValue( + new Response("legacy-admin"), + ); + vi.mocked(handleLegacyArchiveFile).mockResolvedValue( + new Response("legacy-file"), + ); + vi.mocked(handleMapTileRequest).mockResolvedValue(new Response("tile")); + vi.mocked(handleAdminWs).mockResolvedValue(new Response("ws")); + }); + + it("serves healthz directly from Hono bindings", async () => { + const response = await apiApp.fetch( + request("/healthz"), + env as any, + executionCtx, + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + ok: true, + service: "insightflare", + bindings: { d1: true, durableObject: true, r2Archive: true }, + }); + }); + + it("serves dynamic well-known OpenAPI with forwarded host", async () => { + const response = await apiApp.fetch( + request("/.well-known/openapi.json", { + headers: { + "x-forwarded-host": "edge.example.test", + "x-forwarded-proto": "https", + }, + }), + env as any, + executionCtx, + ); + const body = (await response.json()) as { + servers: Array<{ url: string }>; + }; + + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(body.servers[0].url).toBe("https://edge.example.test"); + }); + + it("routes edge endpoints to their shared handlers", async () => { + await apiApp.fetch( + request("/collect", { method: "OPTIONS" }), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/collect", { method: "POST" }), + env as any, + executionCtx, + ); + await apiApp.fetch(request("/script.js"), env as any, executionCtx); + await apiApp.fetch(request("/admin/ws"), env as any, executionCtx); + + expect(handleCollectOptionsRequest).toHaveBeenCalled(); + expect(handleCollectRequest).toHaveBeenCalledWith( + expect.any(Request), + env, + executionCtx, + new URL("https://app.test/collect"), + ); + expect(handleTrackerScriptRequest).toHaveBeenCalledWith( + expect.any(Request), + env, + ); + expect(handleAdminWs).toHaveBeenCalledWith(expect.any(Request), env); + }); + + it("routes private, public, and v1 API groups through Hono", async () => { + await apiApp.fetch( + request("/api/private/admin/users"), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/private/archive/manifest"), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/private/overview"), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/public/demo/site"), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/v1/capabilities"), + env as any, + executionCtx, + ); + + expect(handleUsersAdmin).toHaveBeenCalled(); + expect(handlePrivateAdmin).not.toHaveBeenCalled(); + expect(handlePrivateArchiveManifest).toHaveBeenCalled(); + expect(handlePrivateArchive).not.toHaveBeenCalled(); + expect(resolvePrivateSite).toHaveBeenCalled(); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "overview", + new URL("https://app.test/api/private/overview"), + { publicMode: false }, + expect.any(Request), + ); + expect(handlePrivateQuery).not.toHaveBeenCalled(); + expect(fetchPublicSite).toHaveBeenCalled(); + expect(handlePublicQuery).not.toHaveBeenCalled(); + expect(handleCapabilities).toHaveBeenCalled(); + expect(handleApiV1).not.toHaveBeenCalled(); + }); + + it("routes legacy and map endpoints through Hono", async () => { + await apiApp.fetch( + request("/api/auth/login", { method: "POST" }), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/admin/user", { method: "POST" }), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/archive/file?key=a", { method: "HEAD" }), + env as any, + executionCtx, + ); + await apiApp.fetch( + request("/api/map-tiles/1/0/0.png"), + env as any, + executionCtx, + ); + + expect(handleLegacyAuthLogin).toHaveBeenCalled(); + expect(handleLegacyAdminUser).toHaveBeenCalled(); + expect(handleLegacyArchiveFile).toHaveBeenCalled(); + expect(handleMapTileRequest).toHaveBeenCalledWith(expect.any(Request), { + z: "1", + x: "0", + y: "0.png", + }); + }); +}); diff --git a/src/lib/hono/__tests__/fixtures/api-route-inventory.before.md b/src/lib/hono/__tests__/fixtures/api-route-inventory.before.md new file mode 100644 index 00000000..cf7b0879 --- /dev/null +++ b/src/lib/hono/__tests__/fixtures/api-route-inventory.before.md @@ -0,0 +1,51 @@ +# API Route Inventory Before Hono-Native Refactor + +Baseline HEAD: `70b09f0f820087f61f57839a2c68d1b2644691a1` + +This inventory captures the production route surface after the Hono entry +migration and before the part 2 handler refactor. It is used as a parity +checklist; it is not an OpenAPI contract. + +| Method | Path pattern | Current Hono route | Current production handler | Auth / scope | Site resolution | Cache / headers | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| OPTIONS | `/collect` | `routes/collect.ts` | `handleCollectOptionsRequest` | none | payload site only for POST | CORS 204 | Preflight only | +| POST | `/collect` | `routes/collect.ts` | `handleCollectRequest` | none | site settings by payload/query siteId | CORS 204, body limit, DO waitUntil | Bot/origin/path/custom event checks | +| GET | `/script.js` | `routes/tracker-script.ts` | `handleTrackerScriptRequest` | none | optional site config query | JS content/cache headers | Tracker SDK endpoint | +| GET | `/healthz` | `routes/health.ts` | inline Hono health handler | none | none | JSON | Binding status output | +| GET/HEAD | `/.well-known/openapi.json` | `routes/well-known.ts` | inline Hono handler | none | none | CORS/cache JSON | Dynamic server base URL | +| GET/HEAD | `/.well-known/skills.json` | `routes/well-known.ts` | inline Hono handler | none | none | CORS/cache JSON | Dynamic `${baseUrl}` replacement | +| GET/HEAD | `/.well-known/security.txt` | `routes/well-known.ts` | inline Hono handler | none | none | CORS/cache text | Static text | +| GET/HEAD | `/.well-known/change-password` | `routes/well-known.ts` | inline Hono handler | none | none | redirect/HEAD 200 | Redirects to `/app` | +| GET/HEAD | `/.well-known/health` | `routes/well-known.ts` | inline Hono handler | none | none | redirect/HEAD 200 | Redirects to `/healthz` | +| ALL | `/admin/ws` | `routes/admin-ws.ts` | `handleAdminWs` | dashboard session | query `siteId` membership | DO websocket forward | Preserves admin bypass | +| GET | `/api/map-tiles/:z/:x/:y(.png)` | `routes/map-tiles.ts` | `handleMapTileRequest` | same-origin | none | upstream cache headers | Supports x wrap and dark fallback | +| ALL | `/api/private/admin/:adminPath` | `routes/private/index.ts` | `handlePrivateAdmin` | admin/session in sub-handler | sub-handler dependent | JSON | Pathname router still in production | +| ALL | `/api/private/archive/manifest` | `routes/private/index.ts` | `handlePrivateArchive` | session | `siteId` membership | JSON | Pathname router still in production | +| GET/HEAD | `/api/private/archive/file` | `routes/private/index.ts` | `handlePrivateArchive` | session | archive row site membership | Range/ETag streaming | Pathname router still in production | +| GET | `/api/private/:queryPath` | `routes/private/index.ts` | `handlePrivateQuery` -> `routeQuery` | session | private site from query | private dashboard cache after auth/site | Pathname router still in production | +| POST/DELETE | `/api/private/funnels` | `routes/private/index.ts` | `handlePrivateQuery` -> `routeQuery` | session | private site from query | no read cache | Funnel mutation exception | +| ALL | `/api/private/team-dashboard` | `routes/private/index.ts` | `handlePrivateQuery` | session in handler | team context | no dashboard site cache | Special dashboard route | +| GET | `/api/public/:slug/site` | `routes/public.ts` | `handlePublicQuery` | none | public enabled slug | public cache | Public site data | +| GET | `/api/public/:slug/:queryPath` | `routes/public.ts` | `handlePublicQuery` -> `routeQuery` | none | public enabled slug | public cache | Allowlist via `PUBLIC_QUERY_PATHS` | +| POST/PATCH/DELETE | `/api/public/:slug/:queryPath` | `routes/public.ts` | `handlePublicQuery` | none | none if method rejected first | 405 JSON | Public API is GET-only | +| POST | `/api/auth/login` | `routes/auth.ts` | `handleLegacyAuthLogin` | credentials | none | Set-Cookie | Hono path avoids internal HTTP | +| POST | `/api/auth/logout` | `routes/auth.ts` | `handleLegacyAuthLogout` | none | none | Clear Set-Cookie | Legacy compatibility | +| POST | `/api/admin/user` | `routes/legacy-admin.ts` | `handleLegacyAdminUser` | same-origin + private admin auth | private admin handler | JSON | Hono path avoids internal HTTP | +| POST | `/api/admin/team` | `routes/legacy-admin.ts` | `handleLegacyAdminTeam` | same-origin + private admin auth | private admin handler | JSON | Legacy form intents | +| POST | `/api/admin/site` | `routes/legacy-admin.ts` | `handleLegacyAdminSite` | same-origin + private admin auth | private admin handler | JSON | Legacy form intents | +| POST | `/api/admin/member` | `routes/legacy-admin.ts` | `handleLegacyAdminMember` | same-origin + private admin auth | private admin handler | JSON | Legacy form intents | +| POST | `/api/admin/profile` | `routes/legacy-admin.ts` | `handleLegacyAdminProfile` | same-origin + private admin auth | private admin handler | JSON | Profile update | +| POST | `/api/admin/site-config` | `routes/legacy-admin.ts` | `handleLegacyAdminSiteConfig` | same-origin + private admin auth | private admin handler | JSON | Legacy privacy form | +| GET | `/api/archive/manifest` | `routes/legacy-archive.ts` | `handleLegacyArchiveManifest` | session via private archive | query `siteId` membership | JSON | Rewrites `fetchUrl` to legacy path | +| GET/HEAD | `/api/archive/file` | `routes/legacy-archive.ts` | `handleLegacyArchiveFile` | session via private archive | archive row site membership | Range/ETag streaming | Header passthrough | +| GET | `/api/v1` | `routes/v1/index.ts` | `handleApiV1` | none | none | JSON v1 envelope | Root docs/capabilities links | +| ALL | `/api/v1/token` | `routes/v1/index.ts` | `handleApiV1` | API key | principal team | JSON v1 envelope | Segments router still in production | +| ALL | `/api/v1/token/check` | `routes/v1/index.ts` | `handleApiV1` | API key | principal team | JSON v1 envelope | Segments router still in production | +| ALL | `/api/v1/capabilities` | `routes/v1/index.ts` | `handleApiV1` | API key | principal team | JSON v1 envelope | Segments router still in production | +| ALL | `/api/v1/team/*` | `routes/v1/index.ts` | `handleApiV1` | API key + scopes | principal team | JSON v1 envelope | Segments router still in production | +| ALL | `/api/v1/sites` | `routes/v1/index.ts` | `handleApiV1` | API key + scopes | principal team/sites | JSON v1 envelope | Segments router still in production | +| ALL | `/api/v1/sites/:siteId/*` | `routes/v1/index.ts` | `handleApiV1` | API key + scopes | `siteById`/access semantics | JSON v1 envelope | Segments router still in production | +| POST | `/api/v1/batch` | `routes/v1/index.ts` | `handleApiV1` | API key | per subrequest | JSON v1 envelope | Subrequests currently call `handleApiV1` | + +Production path match is controlled by `src/lib/hono/path-match.ts`; non-API +page traffic continues to OpenNext. diff --git a/src/lib/hono/__tests__/middleware-foundation.test.ts b/src/lib/hono/__tests__/middleware-foundation.test.ts new file mode 100644 index 00000000..2a7d4767 --- /dev/null +++ b/src/lib/hono/__tests__/middleware-foundation.test.ts @@ -0,0 +1,521 @@ +import { type Handler, Hono, type MiddlewareHandler } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ApiKeyPrincipal } from "@/lib/edge/api-key-auth"; +import type * as ApiKeyAuthModule from "@/lib/edge/api-key-auth"; +import type { Env } from "@/lib/edge/types"; +import { + authenticateApiKeyMiddleware, + requireApiScopeMiddleware, +} from "@/lib/hono/middleware/api-key"; +import { normalizeJsonBodyMiddleware } from "@/lib/hono/middleware/body"; +import { dashboardCacheMiddleware } from "@/lib/hono/middleware/dashboard-cache"; +import { + errorBoundaryMiddleware, + handleHonoError, +} from "@/lib/hono/middleware/error-boundary"; +import { + requireMethodMiddleware, + requireMethodsMiddleware, +} from "@/lib/hono/middleware/method"; +import { requestIdMiddleware } from "@/lib/hono/middleware/request-id"; +import { sameOriginMiddleware } from "@/lib/hono/middleware/same-origin"; +import { requireSessionMiddleware } from "@/lib/hono/middleware/session"; +import { + resolveApiSiteMiddleware, + resolvePrivateSiteMiddleware, + resolvePublicSiteMiddleware, +} from "@/lib/hono/middleware/site"; +import type { AppEnv } from "@/lib/hono/types"; +import { responseContext } from "@/lib/hono/utils/context"; + +vi.mock("@/lib/edge/api-key-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + authenticateApiKey: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/dashboard-cache", () => ({ + withDashboardCache: vi.fn( + async ( + _ctx: ExecutionContext, + _url: URL, + loader: () => Promise, + ) => loader(), + ), +})); + +vi.mock("@/lib/edge/query/core", () => ({ + fetchPublicSite: vi.fn(), + resolvePrivateSite: vi.fn(), +})); + +vi.mock("@/lib/edge/session-auth", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/edge/utils", () => ({ + requireSameOrigin: vi.fn(), +})); + +const { authenticateApiKey } = await import("@/lib/edge/api-key-auth"); +const { withDashboardCache } = await import("@/lib/edge/dashboard-cache"); +const { fetchPublicSite, resolvePrivateSite } = + await import("@/lib/edge/query/core"); +const { requireSession } = await import("@/lib/edge/session-auth"); +const { requireSameOrigin } = await import("@/lib/edge/utils"); + +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +const principal: ApiKeyPrincipal = { + keyId: "key-1", + teamId: "team-1", + prefix: "if_123", + scopes: ["analytics:read"], + siteIds: ["site-1"], +}; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp( + middleware: MiddlewareHandler, + handler: Handler, +) { + const app = new Hono(); + app.use("*", middleware); + app.all("*", handler); + return app; +} + +function createEnv(first: unknown = null): Env { + return { + DB: { + prepare: vi.fn(() => ({ + bind: vi.fn(() => ({ + first: vi.fn(async () => first), + })), + })), + }, + } as unknown as Env; +} + +describe("Hono middleware foundation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSameOrigin).mockReturnValue(null); + }); + + it("stores the shared request id value", async () => { + const app = createApp(requestIdMiddleware(), (c) => + c.json({ requestId: c.get("requestId") }), + ); + + const response = await app.fetch( + request("/api/private/overview", { + headers: { "x-request-id": "req-123" }, + }), + createEnv(), + ctx, + ); + + await expect(response.json()).resolves.toEqual({ requestId: "req-123" }); + }); + + it("returns the shared response context from Hono variables", () => { + expect( + responseContext({ + get: (key: string) => (key === "requestId" ? "req-ctx" : undefined), + } as never), + ).toEqual({ requestId: "req-ctx" }); + }); + + it("maps thrown errors through the shared error response", async () => { + const app = new Hono(); + app.onError(handleHonoError); + app.get("*", () => { + throw new Error("boom"); + }); + + const response = await app.fetch(request("/api/private/overview"), {}, ctx); + const body = (await response.json()) as { error: { code: string } }; + + expect(response.status).toBe(500); + expect(body.error.code).toBe("internal_server_error"); + }); + + it("passes thrown Response values through the error boundary middleware", async () => { + const app = createApp(errorBoundaryMiddleware(), () => { + throw new Response("teapot", { status: 418 }); + }); + + const response = await app.fetch(request("/api/private/overview"), {}, ctx); + + expect(response.status).toBe(418); + await expect(response.text()).resolves.toBe("teapot"); + }); + + it("short-circuits unsafe cross-origin requests", async () => { + vi.mocked(requireSameOrigin).mockReturnValue( + new Response("Forbidden", { status: 403 }), + ); + const app = createApp(sameOriginMiddleware(), () => new Response("ok")); + + const response = await app.fetch( + request("/api/admin/user", { + method: "POST", + headers: { origin: "https://evil.test" }, + }), + createEnv(), + ctx, + ); + + expect(response.status).toBe(403); + await expect(response.text()).resolves.toBe("Forbidden"); + }); + + it("continues same-origin middleware when the shared helper allows the request", async () => { + const app = createApp(sameOriginMiddleware(), () => new Response("ok")); + + const response = await app.fetch( + request("/api/admin/user", { method: "POST" }), + createEnv(), + ctx, + ); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("ok"); + }); + + it("guards exact and grouped methods with the API v1 response shape", async () => { + const exact = createApp(requireMethodMiddleware("POST"), () => + Response.json({ ok: true }), + ); + const grouped = createApp(requireMethodsMiddleware(["GET", "HEAD"]), () => + Response.json({ ok: true }), + ); + + const exactResponse = await exact.fetch( + request("/api/v1/sites", { method: "GET" }), + createEnv(), + ctx, + ); + const groupedResponse = await grouped.fetch( + request("/api/v1/sites", { method: "POST" }), + createEnv(), + ctx, + ); + + expect(exactResponse.status).toBe(405); + expect(groupedResponse.status).toBe(405); + await expect(exactResponse.json()).resolves.toMatchObject({ + error: { code: "method_not_allowed" }, + }); + }); + + it("continues allowed method middleware branches", async () => { + const exact = createApp(requireMethodMiddleware("POST"), () => + Response.json({ ok: true }), + ); + const grouped = createApp(requireMethodsMiddleware(["GET", "HEAD"]), () => + Response.json({ ok: true }), + ); + + const exactResponse = await exact.fetch( + request("/api/v1/sites", { method: "POST" }), + createEnv(), + ctx, + ); + const groupedResponse = await grouped.fetch( + request("/api/v1/sites", { method: "HEAD" }), + createEnv(), + ctx, + ); + + expect(exactResponse.status).toBe(200); + expect(groupedResponse.status).toBe(200); + }); + + it("normalizes JSON bodies by replacing the raw request body", async () => { + const app = createApp( + normalizeJsonBodyMiddleware((body) => ({ ...body, added: true })), + async (c) => Response.json(await c.req.raw.json()), + ); + + const response = await app.fetch( + request("/api/admin/site", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Site" }), + }), + createEnv(), + ctx, + ); + + await expect(response.json()).resolves.toEqual({ + name: "Site", + added: true, + }); + }); + + it("stores authenticated session claims", async () => { + vi.mocked(requireSession).mockResolvedValue({ + userId: "user-1", + username: "user", + displayName: "User", + systemRole: "user", + exp: 1, + }); + const app = createApp(requireSessionMiddleware(), (c) => + c.json({ userId: c.get("session")?.userId }), + ); + + const response = await app.fetch( + request("/api/private/admin/users"), + createEnv(), + ctx, + ); + + await expect(response.json()).resolves.toEqual({ userId: "user-1" }); + }); + + it("short-circuits missing sessions with the shared unauthorized response", async () => { + vi.mocked(requireSession).mockResolvedValue(null); + const app = createApp(requireSessionMiddleware(), () => + Response.json({ ok: true }), + ); + + const response = await app.fetch( + request("/api/private/admin/users"), + createEnv(), + ctx, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "unauthorized" }, + }); + }); + + it("stores API key principals and reuses the shared scope checks", async () => { + vi.mocked(authenticateApiKey).mockResolvedValue(principal); + const app = createApp(authenticateApiKeyMiddleware(), (c) => + c.json({ teamId: c.get("apiPrincipal")?.teamId }), + ); + + const response = await app.fetch( + request("/api/v1/sites"), + createEnv(), + ctx, + ); + + await expect(response.json()).resolves.toEqual({ teamId: "team-1" }); + }); + + it("short-circuits invalid API keys and insufficient API scopes", async () => { + vi.mocked(authenticateApiKey).mockResolvedValueOnce( + new Response("invalid", { status: 401 }), + ); + const authApp = createApp(authenticateApiKeyMiddleware(), () => + Response.json({ ok: true }), + ); + const deniedApp = createApp(requireApiScopeMiddleware("site:write"), () => + Response.json({ ok: true }), + ); + + const authResponse = await authApp.fetch( + request("/api/v1/sites"), + createEnv(), + ctx, + ); + vi.mocked(authenticateApiKey).mockResolvedValueOnce(principal); + const deniedResponse = await deniedApp.fetch( + request("/api/v1/sites"), + createEnv(), + ctx, + ); + + expect(authResponse.status).toBe(401); + await expect(authResponse.text()).resolves.toBe("invalid"); + expect(deniedResponse.status).toBe(403); + await expect(deniedResponse.json()).resolves.toMatchObject({ + error: { code: "insufficient_scope" }, + }); + }); + + it("continues API scope middleware when a principal is already available", async () => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("apiPrincipal", principal); + await next(); + }); + app.use("*", requireApiScopeMiddleware("analytics:read")); + app.get("*", (c) => c.json({ teamId: c.get("apiPrincipal")?.teamId })); + + const response = await app.fetch( + request("/api/v1/sites"), + createEnv(), + ctx, + ); + + await expect(response.json()).resolves.toEqual({ teamId: "team-1" }); + }); + + it("resolves private, public, and API site context", async () => { + vi.mocked(resolvePrivateSite).mockResolvedValue({ + id: "private-site", + name: "Private Site", + domain: "app.test", + }); + vi.mocked(fetchPublicSite).mockResolvedValue({ + id: "public-site", + name: "Public Site", + domain: "app.test", + }); + + const privateApp = createApp(resolvePrivateSiteMiddleware(), (c) => + c.json({ id: c.get("privateSite")?.id }), + ); + const publicApp = new Hono(); + publicApp.use("/:slug/*", resolvePublicSiteMiddleware()); + publicApp.get("/:slug/site", (c) => + c.json({ slug: c.get("publicSite")?.slug }), + ); + const apiApp = new Hono(); + apiApp.use("*", async (c, next) => { + c.set("apiPrincipal", principal); + await next(); + }); + apiApp.use("/sites/:siteId/*", resolveApiSiteMiddleware()); + apiApp.get("/sites/:siteId/overview", (c) => + c.json({ id: c.get("apiSite")?.id }), + ); + + const privateResponse = await privateApp.fetch( + request("/api/private/overview"), + createEnv(), + ctx, + ); + const publicResponse = await publicApp.fetch( + request("/demo/site"), + createEnv(), + ctx, + ); + const apiResponse = await apiApp.fetch( + request("/sites/site-1/overview"), + createEnv({ + id: "site-1", + teamId: "team-1", + name: "API Site", + domain: "api.test", + publicEnabled: 0, + publicSlug: null, + createdAt: 1, + updatedAt: 2, + }), + ctx, + ); + + await expect(privateResponse.json()).resolves.toEqual({ + id: "private-site", + }); + await expect(publicResponse.json()).resolves.toEqual({ slug: "demo" }); + await expect(apiResponse.json()).resolves.toEqual({ id: "site-1" }); + }); + + it("passes through site resolver response failures", async () => { + vi.mocked(resolvePrivateSite).mockResolvedValueOnce( + new Response("private-denied", { status: 403 }), + ); + vi.mocked(fetchPublicSite).mockResolvedValueOnce( + new Response("public-missing", { status: 404 }), + ); + const privateApp = createApp(resolvePrivateSiteMiddleware(), () => + Response.json({ ok: true }), + ); + const publicApp = createApp(resolvePublicSiteMiddleware(), () => + Response.json({ ok: true }), + ); + + const privateResponse = await privateApp.fetch( + request("/api/private/overview"), + createEnv(), + ctx, + ); + const publicResponse = await publicApp.fetch( + request("/demo/site"), + createEnv(), + ctx, + ); + + expect(privateResponse.status).toBe(403); + await expect(privateResponse.text()).resolves.toBe("private-denied"); + expect(publicResponse.status).toBe(404); + await expect(publicResponse.text()).resolves.toBe("public-missing"); + }); + + it("returns not found when API site context is absent or inaccessible", async () => { + const app = new Hono(); + app.use("/sites/:siteId/*", resolveApiSiteMiddleware()); + app.get("/sites/:siteId/overview", () => Response.json({ ok: true })); + + const response = await app.fetch( + request("/sites/site-1/overview"), + createEnv(), + ctx, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "site_not_found" }, + }); + }); + + it("returns not found when API site lookup misses", async () => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("apiPrincipal", principal); + await next(); + }); + app.use("/sites/:siteId/*", resolveApiSiteMiddleware()); + app.get("/sites/:siteId/overview", () => Response.json({ ok: true })); + + const response = await app.fetch( + request("/sites/site-1/overview"), + createEnv(null), + ctx, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "site_not_found" }, + }); + }); + + it("wraps responses with dashboard cache middleware", async () => { + const app = createApp( + dashboardCacheMiddleware({ ttlSeconds: 30 }), + () => new Response("cached"), + ); + + const response = await app.fetch( + request("/api/private/overview"), + createEnv(), + ctx, + ); + + await expect(response.text()).resolves.toBe("cached"); + expect(withDashboardCache).toHaveBeenCalledWith( + ctx, + new URL("https://app.test/api/private/overview"), + expect.any(Function), + { ttlSeconds: 30 }, + ); + }); +}); diff --git a/src/lib/hono/__tests__/parity-helpers.test.ts b/src/lib/hono/__tests__/parity-helpers.test.ts new file mode 100644 index 00000000..862a6e61 --- /dev/null +++ b/src/lib/hono/__tests__/parity-helpers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + expectResponsesToMatch, + normalizeResponse, +} from "@/lib/hono/__tests__/parity-helpers"; + +describe("Hono parity helpers", () => { + it("normalizes dynamic JSON fields and session cookie values", async () => { + const first = new Response( + JSON.stringify({ + ok: true, + requestId: "a", + timestamp: "2026-01-01T00:00:00.000Z", + data: { id: "site-1", now: "dynamic" }, + }), + { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "set-cookie": + "if_session=abc; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400", + }, + }, + ); + const second = new Response( + JSON.stringify({ + ok: true, + requestId: "b", + timestamp: "2026-01-02T00:00:00.000Z", + data: { id: "site-1", now: "other" }, + }), + { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "set-cookie": + "if_session=xyz; Path=/; HttpOnly; SameSite=Lax; Max-Age=10", + }, + }, + ); + + await expectResponsesToMatch(first, second); + }); + + it("keeps status, compared headers, and non-json body text strict", async () => { + const normalized = await normalizeResponse( + new Response("plain", { + status: 206, + headers: { + "content-range": "bytes 0-4/10", + "content-length": "5", + }, + }), + ); + + expect(normalized).toEqual({ + status: 206, + headers: { + "content-length": "5", + "content-range": "bytes 0-4/10", + "content-type": "text/plain;charset=UTF-8", + }, + bodyText: "plain", + json: null, + }); + }); +}); diff --git a/src/lib/hono/__tests__/parity-helpers.ts b/src/lib/hono/__tests__/parity-helpers.ts new file mode 100644 index 00000000..beff5151 --- /dev/null +++ b/src/lib/hono/__tests__/parity-helpers.ts @@ -0,0 +1,109 @@ +import { expect } from "vitest"; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface NormalizedResponse { + status: number; + headers: Record; + bodyText: string; + json: JsonValue | null; +} + +const DYNAMIC_JSON_KEYS = new Set(["requestId", "timestamp", "date", "now"]); + +const COMPARED_HEADERS = [ + "access-control-allow-origin", + "cache-control", + "content-length", + "content-range", + "content-type", + "etag", + "location", + "set-cookie", + "vary", + "x-edge-cache", +] as const; + +function normalizeSetCookie(value: string): string { + if (!value) return ""; + return value + .split(/,(?=\s*[^;,]+=)/) + .map((cookie) => + cookie + .replace(/(if_session=)[^;]*/g, "$1") + .replace(/(Max-Age=)\d+/gi, "$1") + .trim(), + ) + .join(", "); +} + +function normalizeJson(value: unknown): JsonValue | null { + if (value === null) return null; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeJson(item) as JsonValue); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (DYNAMIC_JSON_KEYS.has(key)) continue; + out[key] = normalizeJson(child) as JsonValue; + } + return out; + } + return null; +} + +function normalizeHeader(name: string, value: string): string { + if (name.toLowerCase() === "set-cookie") { + return normalizeSetCookie(value); + } + return value; +} + +export async function normalizeResponse( + response: Response, +): Promise { + const clone = response.clone(); + const bodyText = await clone.text(); + let json: JsonValue | null = null; + try { + json = normalizeJson(JSON.parse(bodyText)); + } catch { + json = null; + } + + const headers: Record = {}; + for (const name of COMPARED_HEADERS) { + const value = response.headers.get(name); + if (value !== null) headers[name] = normalizeHeader(name, value); + } + + return { + status: response.status, + headers, + bodyText: json === null ? bodyText : "", + json, + }; +} + +export async function expectResponsesToMatch( + actual: Response, + expected: Response, +): Promise { + expect(await normalizeResponse(actual)).toEqual( + await normalizeResponse(expected), + ); +} diff --git a/src/lib/hono/__tests__/path-match.test.ts b/src/lib/hono/__tests__/path-match.test.ts new file mode 100644 index 00000000..128f4afb --- /dev/null +++ b/src/lib/hono/__tests__/path-match.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { shouldUseHono } from "@/lib/hono/path-match"; + +describe("shouldUseHono", () => { + it.each([ + "/api/private/overview", + "/api/public/demo/site", + "/api/v1/capabilities", + "/api/map-tiles/1/0/0.png", + "/collect", + "/script.js", + "/healthz", + "/.well-known/openapi.json", + "/.well-known/security.txt", + "/admin/ws", + ])("routes %s through the Hono app", (pathname) => { + expect(shouldUseHono(pathname)).toBe(true); + }); + + it.each([ + "/", + "/app", + "/login", + "/zh/app/team/site", + "/_next/static/chunk.js", + "/favicon.ico", + "/api", + "/collect/", + "/admin/ws/extra", + ])("leaves %s on the OpenNext path", (pathname) => { + expect(shouldUseHono(pathname)).toBe(false); + }); +}); diff --git a/src/lib/hono/__tests__/private-admin-routes.test.ts b/src/lib/hono/__tests__/private-admin-routes.test.ts new file mode 100644 index 00000000..0be7be68 --- /dev/null +++ b/src/lib/hono/__tests__/private-admin-routes.test.ts @@ -0,0 +1,142 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleApiKeysAdmin } from "@/lib/edge/admin-api-keys"; +import { nf } from "@/lib/edge/admin-response"; +import { handleScheduledTasksAdmin } from "@/lib/edge/admin-scheduled-tasks"; +import { + handleScriptSnippetAdmin, + handleSiteConfigAdmin, + handleSitesAdmin, +} from "@/lib/edge/admin-sites"; +import { + handleDoDiagnosticAdmin, + handleSystemPerformanceAdmin, +} from "@/lib/edge/admin-system"; +import { handleMembersAdmin, handleTeamsAdmin } from "@/lib/edge/admin-teams"; +import { + handleAuthLoginAdmin, + handleAuthMeAdmin, + handleProfileAdmin, + handleUsersAdmin, +} from "@/lib/edge/admin-users"; +import { privateAdminRoutes } from "@/lib/hono/routes/private/admin"; +import type { AppEnv } from "@/lib/hono/types"; + +vi.mock("@/lib/edge/admin-api-keys", () => ({ + handleApiKeysAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-response", () => ({ + nf: vi.fn(() => new Response("not found", { status: 404 })), +})); + +vi.mock("@/lib/edge/admin-scheduled-tasks", () => ({ + handleScheduledTasksAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-sites", () => ({ + handleScriptSnippetAdmin: vi.fn(), + handleSiteConfigAdmin: vi.fn(), + handleSitesAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-system", () => ({ + handleDoDiagnosticAdmin: vi.fn(), + handleSystemPerformanceAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-teams", () => ({ + handleMembersAdmin: vi.fn(), + handleTeamsAdmin: vi.fn(), +})); + +vi.mock("@/lib/edge/admin-users", () => ({ + handleAuthLoginAdmin: vi.fn(), + handleAuthMeAdmin: vi.fn(), + handleProfileAdmin: vi.fn(), + handleUsersAdmin: vi.fn(), +})); + +const env = { DB: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +const routeCases = [ + ["/auth/login", handleAuthLoginAdmin, false], + ["/auth/me", handleAuthMeAdmin, false], + ["/users", handleUsersAdmin, false], + ["/profile", handleProfileAdmin, false], + ["/teams", handleTeamsAdmin, false], + ["/sites", handleSitesAdmin, true], + ["/members", handleMembersAdmin, true], + ["/site-config", handleSiteConfigAdmin, true], + ["/script-snippet", handleScriptSnippetAdmin, true], + ["/api-keys", handleApiKeysAdmin, true], + ["/system-performance", handleSystemPerformanceAdmin, true, true], + ["/scheduled-tasks", handleScheduledTasksAdmin, true, true], + ["/do-diagnostic", handleDoDiagnosticAdmin, true, true], +] as const; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp() { + const app = new Hono(); + app.route("/api/private/admin", privateAdminRoutes); + return app; +} + +describe("Hono private admin routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const [, handler] of routeCases) { + vi.mocked(handler).mockResolvedValue(new Response("ok")); + } + }); + + it("routes each admin path directly to its handler", async () => { + for (const [path, handler, includesUrl, includesActor] of routeCases) { + const app = createApp(); + const response = await app.fetch( + request(`/api/private/admin${path}`), + env as never, + ctx, + ); + + expect(response.status).toBe(200); + if (includesActor) { + expect(handler).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL(`https://app.test/api/private/admin${path}`), + expect.any(Function), + ); + } else if (includesUrl) { + expect(handler).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL(`https://app.test/api/private/admin${path}`), + ); + } else { + expect(handler).toHaveBeenCalledWith(expect.any(Request), env); + } + } + }); + + it("returns the shared admin not found response for unknown admin paths", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/admin/unknown"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + expect(nf).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/hono/__tests__/private-archive-routes.test.ts b/src/lib/hono/__tests__/private-archive-routes.test.ts new file mode 100644 index 00000000..af9c7574 --- /dev/null +++ b/src/lib/hono/__tests__/private-archive-routes.test.ts @@ -0,0 +1,90 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; +import { privateArchiveRoutes } from "@/lib/hono/routes/private/archive"; +import type { AppEnv } from "@/lib/hono/types"; + +vi.mock("@/lib/edge/archive-query", () => ({ + handlePrivateArchiveFile: vi.fn(), + handlePrivateArchiveManifest: vi.fn(), +})); + +const env = { DB: {}, ARCHIVE_BUCKET: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp() { + const app = new Hono(); + app.route("/api/private/archive", privateArchiveRoutes); + return app; +} + +describe("Hono private archive routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(handlePrivateArchiveManifest).mockResolvedValue( + new Response("manifest"), + ); + vi.mocked(handlePrivateArchiveFile).mockResolvedValue(new Response("file")); + }); + + it("routes archive manifest directly to its handler", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/archive/manifest?siteId=site-1"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("manifest"); + expect(handlePrivateArchiveManifest).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL("https://app.test/api/private/archive/manifest?siteId=site-1"), + ); + }); + + it("routes archive file GET and HEAD directly to its handler", async () => { + const app = createApp(); + + const getResponse = await app.fetch( + request("/api/private/archive/file?key=a"), + env as never, + ctx, + ); + const headResponse = await app.fetch( + request("/api/private/archive/file?key=a", { method: "HEAD" }), + env as never, + ctx, + ); + + expect(getResponse.status).toBe(200); + expect(headResponse.status).toBe(200); + expect(handlePrivateArchiveFile).toHaveBeenCalledTimes(2); + }); + + it("returns not found for unknown archive paths", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/archive/unknown"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + expect(handlePrivateArchiveManifest).not.toHaveBeenCalled(); + expect(handlePrivateArchiveFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/hono/__tests__/private-query-routes.test.ts b/src/lib/hono/__tests__/private-query-routes.test.ts new file mode 100644 index 00000000..743f39e0 --- /dev/null +++ b/src/lib/hono/__tests__/private-query-routes.test.ts @@ -0,0 +1,202 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { withDashboardCache } from "@/lib/edge/dashboard-cache"; +import type * as QueryCoreModule from "@/lib/edge/query/core"; +import { resolvePrivateSite } from "@/lib/edge/query/core"; +import type * as QueryRouterModule from "@/lib/edge/query/router"; +import { dispatchQueryRoute } from "@/lib/edge/query/router"; +import { handleTeamDashboard } from "@/lib/edge/query/team"; +import { privateQueryRoutes } from "@/lib/hono/routes/private/query"; +import type { AppEnv } from "@/lib/hono/types"; + +vi.mock("@/lib/edge/dashboard-cache", () => ({ + withDashboardCache: vi.fn( + async ( + _ctx: ExecutionContext, + _url: URL, + loader: () => Promise, + ) => loader(), + ), +})); + +vi.mock("@/lib/edge/query/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolvePrivateSite: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/query/router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchQueryRoute: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/query/team", () => ({ + handleTeamDashboard: vi.fn(), +})); + +const env = { DB: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp() { + const app = new Hono(); + app.route("/api/private", privateQueryRoutes); + return app; +} + +describe("Hono private query routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolvePrivateSite).mockResolvedValue({ + id: "site-1", + name: "Site", + domain: "app.test", + }); + vi.mocked(dispatchQueryRoute).mockResolvedValue(new Response("query")); + vi.mocked(handleTeamDashboard).mockResolvedValue(new Response("team")); + }); + + it("routes read-only dashboard queries through site resolution and cache", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/overview?siteId=site-1"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("query"); + expect(resolvePrivateSite).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL("https://app.test/api/private/overview?siteId=site-1"), + ); + expect(withDashboardCache).toHaveBeenCalledWith( + ctx, + new URL("https://app.test/api/private/overview?siteId=site-1"), + expect.any(Function), + undefined, + ); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "overview", + new URL("https://app.test/api/private/overview?siteId=site-1"), + { publicMode: false }, + expect.any(Request), + ); + }); + + it("does not enter the cache generator when private site resolution fails", async () => { + vi.mocked(resolvePrivateSite).mockResolvedValueOnce( + new Response("denied", { status: 404 }), + ); + const app = createApp(); + + const response = await app.fetch( + request("/api/private/overview?siteId=missing"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("denied"); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("keeps non-funnel mutations out of private query routes", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/overview?siteId=site-1", { method: "POST" }), + env as never, + ctx, + ); + + expect(response.status).toBe(405); + expect(resolvePrivateSite).not.toHaveBeenCalled(); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("allows funnel mutations without dashboard cache", async () => { + const app = createApp(); + + const postResponse = await app.fetch( + request("/api/private/funnels?siteId=site-1", { method: "POST" }), + env as never, + ctx, + ); + const deleteResponse = await app.fetch( + request("/api/private/funnels?siteId=site-1", { method: "DELETE" }), + env as never, + ctx, + ); + + expect(postResponse.status).toBe(200); + expect(deleteResponse.status).toBe(200); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "funnels", + new URL("https://app.test/api/private/funnels?siteId=site-1"), + { publicMode: false }, + expect.any(Request), + ); + }); + + it("keeps team dashboard ahead of site resolution and cache", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/team-dashboard?teamId=team-1"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("team"); + expect(handleTeamDashboard).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL("https://app.test/api/private/team-dashboard?teamId=team-1"), + ); + expect(resolvePrivateSite).not.toHaveBeenCalled(); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("falls back unknown GET queries through the legacy query dispatcher", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/private/unknown?siteId=site-1"), + env as never, + ctx, + ); + + expect(response.status).toBe(200); + expect(withDashboardCache).toHaveBeenCalled(); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "unknown", + new URL("https://app.test/api/private/unknown?siteId=site-1"), + { publicMode: false }, + expect.any(Request), + ); + }); +}); diff --git a/src/lib/hono/__tests__/public-query-routes.test.ts b/src/lib/hono/__tests__/public-query-routes.test.ts new file mode 100644 index 00000000..8dc286a2 --- /dev/null +++ b/src/lib/hono/__tests__/public-query-routes.test.ts @@ -0,0 +1,184 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type * as DashboardCacheModule from "@/lib/edge/dashboard-cache"; +import { + PUBLIC_QUERY_CACHE_OPTIONS, + withDashboardCache, +} from "@/lib/edge/dashboard-cache"; +import type * as QueryCoreModule from "@/lib/edge/query/core"; +import { fetchPublicSite } from "@/lib/edge/query/core"; +import type * as QueryRouterModule from "@/lib/edge/query/router"; +import { dispatchQueryRoute } from "@/lib/edge/query/router"; +import { publicQueryRoutes } from "@/lib/hono/routes/public/query"; +import type { AppEnv } from "@/lib/hono/types"; + +vi.mock("@/lib/edge/dashboard-cache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withDashboardCache: vi.fn( + async ( + _ctx: ExecutionContext, + _url: URL, + loader: () => Promise, + ) => loader(), + ), + }; +}); + +vi.mock("@/lib/edge/query/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchPublicSite: vi.fn(), + }; +}); + +vi.mock("@/lib/edge/query/router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchQueryRoute: vi.fn(), + }; +}); + +const env = { DB: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp() { + const app = new Hono(); + app.route("/api/public", publicQueryRoutes); + return app; +} + +describe("Hono public query routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchPublicSite).mockResolvedValue({ + id: "site-1", + name: "Public Site", + domain: "public.test", + }); + vi.mocked(dispatchQueryRoute).mockResolvedValue(new Response("query")); + }); + + it("returns public site metadata through the public cache wrapper", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/public/demo/site"), + env as never, + ctx, + ); + const body = (await response.json()) as { + ok: boolean; + data: { slug: string; id: string }; + }; + + expect(body).toMatchObject({ + ok: true, + data: { slug: "demo", id: "site-1" }, + }); + expect(fetchPublicSite).toHaveBeenCalledWith( + env, + new URL("https://app.test/api/public/demo/site"), + ); + expect(withDashboardCache).toHaveBeenCalledWith( + ctx, + new URL("https://app.test/api/public/demo/site"), + expect.any(Function), + PUBLIC_QUERY_CACHE_OPTIONS, + ); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("routes public allowlist queries with publicMode and public cache options", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/public/demo/overview?preset=today"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("query"); + expect(withDashboardCache).toHaveBeenCalledWith( + ctx, + new URL("https://app.test/api/public/demo/overview?preset=today"), + expect.any(Function), + PUBLIC_QUERY_CACHE_OPTIONS, + ); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "overview", + new URL("https://app.test/api/public/demo/overview?preset=today"), + { publicMode: true }, + expect.any(Request), + ); + }); + + it("rejects public mutations before site lookup and cache", async () => { + const app = createApp(); + + const response = await app.fetch( + request("/api/public/demo/overview", { method: "POST" }), + env as never, + ctx, + ); + + expect(response.status).toBe(405); + expect(fetchPublicSite).not.toHaveBeenCalled(); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("does not enter cache when public site resolution fails", async () => { + vi.mocked(fetchPublicSite).mockResolvedValueOnce( + new Response("missing", { status: 404 }), + ); + const app = createApp(); + + const response = await app.fetch( + request("/api/public/missing/overview"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("missing"); + expect(withDashboardCache).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); + }); + + it("keeps private-only endpoints behind the public query allowlist", async () => { + vi.mocked(dispatchQueryRoute).mockResolvedValueOnce( + new Response("not found", { status: 404 }), + ); + const app = createApp(); + + const response = await app.fetch( + request("/api/public/demo/events-records"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + expect(dispatchQueryRoute).toHaveBeenCalledWith( + env, + "site-1", + "events-records", + new URL("https://app.test/api/public/demo/events-records"), + { publicMode: true }, + expect.any(Request), + ); + }); +}); diff --git a/src/lib/hono/__tests__/v1-routes.test.ts b/src/lib/hono/__tests__/v1-routes.test.ts new file mode 100644 index 00000000..195e704f --- /dev/null +++ b/src/lib/hono/__tests__/v1-routes.test.ts @@ -0,0 +1,236 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { authenticateApiKey } from "@/lib/edge/api-key-auth"; +import type * as ApiV1Module from "@/lib/edge/api-v1"; +import { + handleAnalytics, + handleApiV1, + handleBatch, + handleCapabilities, + handleEvents, + handleFunnels, + handleJourneys, + handlePerformance, + handlePrivacy, + handleRealtime, + handleRoot, + handleSharing, + handleSiteResource, + handleSitesCollection, + handleTeam, + handleToken, + handleTokenCheck, + handleTracking, + handleTrackingScript, +} from "@/lib/edge/api-v1"; +import { v1Routes } from "@/lib/hono/routes/v1"; +import type { AppEnv } from "@/lib/hono/types"; + +vi.mock("@/lib/edge/api-key-auth", () => ({ + authenticateApiKey: vi.fn(), +})); + +vi.mock("@/lib/edge/api-v1", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + handleAnalytics: vi.fn(), + handleApiV1: vi.fn(), + handleBatch: vi.fn(), + handleCapabilities: vi.fn(), + handleEvents: vi.fn(), + handleFunnels: vi.fn(), + handleJourneys: vi.fn(), + handlePerformance: vi.fn(), + handlePrivacy: vi.fn(), + handleRealtime: vi.fn(), + handleRoot: vi.fn(), + handleSharing: vi.fn(), + handleSiteResource: vi.fn(), + handleSitesCollection: vi.fn(), + handleTeam: vi.fn(), + handleToken: vi.fn(), + handleTokenCheck: vi.fn(), + handleTracking: vi.fn(), + handleTrackingScript: vi.fn(), + }; +}); + +const principal = { + keyId: "key-1", + teamId: "team-1", + prefix: "if_123", + scopes: ["analytics:read" as const], + siteIds: ["site-1"], +}; +const env = { DB: {} }; +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://app.test${path}`, init); +} + +function createApp() { + const app = new Hono(); + app.route("/api/v1", v1Routes); + return app; +} + +describe("Hono API v1 routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(authenticateApiKey).mockResolvedValue(principal); + vi.mocked(handleRoot).mockResolvedValue(new Response("root")); + vi.mocked(handleCapabilities).mockResolvedValue( + new Response("capabilities"), + ); + vi.mocked(handleSitesCollection).mockResolvedValue(new Response("sites")); + vi.mocked(handleAnalytics).mockResolvedValue(new Response("analytics")); + vi.mocked(handleBatch).mockResolvedValue(new Response("batch")); + vi.mocked(handleEvents).mockResolvedValue(new Response("events")); + vi.mocked(handleFunnels).mockResolvedValue(new Response("funnels")); + vi.mocked(handleJourneys).mockResolvedValue(new Response("journeys")); + vi.mocked(handlePerformance).mockResolvedValue(new Response("performance")); + vi.mocked(handlePrivacy).mockResolvedValue(new Response("privacy")); + vi.mocked(handleRealtime).mockResolvedValue(new Response("realtime")); + vi.mocked(handleSharing).mockResolvedValue(new Response("sharing")); + vi.mocked(handleSiteResource).mockResolvedValue( + new Response("site-resource"), + ); + vi.mocked(handleSitesCollection).mockResolvedValue(new Response("sites")); + vi.mocked(handleTeam).mockResolvedValue(new Response("team")); + vi.mocked(handleToken).mockResolvedValue(new Response("token")); + vi.mocked(handleTokenCheck).mockResolvedValue(new Response("token-check")); + vi.mocked(handleTracking).mockResolvedValue(new Response("tracking")); + vi.mocked(handleTrackingScript).mockResolvedValue( + new Response("tracking-script"), + ); + }); + + it("serves the API v1 root without API key auth", async () => { + const response = await createApp().fetch( + request("/api/v1"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("root"); + expect(handleRoot).toHaveBeenCalledWith(expect.any(Request)); + expect(authenticateApiKey).not.toHaveBeenCalled(); + expect(handleApiV1).not.toHaveBeenCalled(); + }); + + it("authenticates non-root routes and dispatches capabilities directly", async () => { + const response = await createApp().fetch( + request("/api/v1/capabilities"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("capabilities"); + expect(authenticateApiKey).toHaveBeenCalled(); + expect(handleCapabilities).toHaveBeenCalledWith( + expect.any(Request), + principal, + ); + expect(handleApiV1).not.toHaveBeenCalled(); + }); + + it("routes site analytics resources with the decoded API v1 path", async () => { + const response = await createApp().fetch( + request("/api/v1/sites/site-1/analytics/overview"), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("analytics"); + expect(handleAnalytics).toHaveBeenCalledWith( + expect.any(Request), + env, + new URL("https://app.test/api/v1/sites/site-1/analytics/overview"), + principal, + "site-1", + ["sites", "site-1", "analytics", "overview"], + ); + }); + + it.each([ + ["/api/v1/token", handleToken, "token"], + ["/api/v1/token/check", handleTokenCheck, "token-check"], + ["/api/v1/team", handleTeam, "team"], + ["/api/v1/team/usage", handleTeam, "team"], + ["/api/v1/sites", handleSitesCollection, "sites"], + ["/api/v1/sites/site-1", handleSiteResource, "site-resource"], + ["/api/v1/sites/site-1/tracking", handleTracking, "tracking"], + [ + "/api/v1/sites/site-1/tracking/script", + handleTrackingScript, + "tracking-script", + ], + ["/api/v1/sites/site-1/privacy", handlePrivacy, "privacy"], + ["/api/v1/sites/site-1/sharing", handleSharing, "sharing"], + ["/api/v1/sites/site-1/analytics/schema", handleAnalytics, "analytics"], + ["/api/v1/sites/site-1/event-types", handleEvents, "events"], + ["/api/v1/sites/site-1/events", handleEvents, "events"], + ["/api/v1/sites/site-1/events/event-1", handleEvents, "events"], + ["/api/v1/sites/site-1/event-fields", handleEvents, "events"], + ["/api/v1/sites/site-1/visitors", handleJourneys, "journeys"], + ["/api/v1/sites/site-1/visitors/visitor-1", handleJourneys, "journeys"], + ["/api/v1/sites/site-1/sessions", handleJourneys, "journeys"], + ["/api/v1/sites/site-1/sessions/session-1", handleJourneys, "journeys"], + ["/api/v1/sites/site-1/funnels", handleFunnels, "funnels"], + ["/api/v1/sites/site-1/funnels/analysis", handleFunnels, "funnels"], + ["/api/v1/sites/site-1/funnels/funnel-1", handleFunnels, "funnels"], + ["/api/v1/sites/site-1/performance", handlePerformance, "performance"], + [ + "/api/v1/sites/site-1/performance/summary", + handlePerformance, + "performance", + ], + ["/api/v1/sites/site-1/realtime", handleRealtime, "realtime"], + ["/api/v1/sites/site-1/realtime/snapshot", handleRealtime, "realtime"], + ])("routes %s directly through Hono", async (route, handler, body) => { + const response = await createApp().fetch(request(route), env as never, ctx); + + await expect(response.text()).resolves.toBe(body); + expect(handler).toHaveBeenCalled(); + expect(handleApiV1).not.toHaveBeenCalled(); + }); + + it("returns the API v1 resource_not_found envelope for unknown resources", async () => { + const response = await createApp().fetch( + request("/api/v1/nope"), + env as never, + ctx, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "resource_not_found" }, + }); + }); + + it("dispatches batch subrequests through the Hono v1 route map", async () => { + vi.mocked(handleBatch).mockImplementation( + async (_request, batchEnv, _url, _principal, dispatch) => + dispatch!( + request("/api/v1/capabilities"), + batchEnv, + new URL("https://app.test/api/v1/capabilities"), + ), + ); + const response = await createApp().fetch( + request("/api/v1/batch", { method: "POST", body: "{}" }), + env as never, + ctx, + ); + + await expect(response.text()).resolves.toBe("capabilities"); + expect(handleCapabilities).toHaveBeenCalled(); + expect(handleApiV1).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/hono/app.ts b/src/lib/hono/app.ts new file mode 100644 index 00000000..454ffd9a --- /dev/null +++ b/src/lib/hono/app.ts @@ -0,0 +1,35 @@ +import { Hono } from "hono"; + +import { handleHonoError } from "./middleware/error-boundary"; +import { adminWsRoutes } from "./routes/admin-ws"; +import { authRoutes } from "./routes/auth"; +import { collectRoutes } from "./routes/collect"; +import { healthRoutes } from "./routes/health"; +import { legacyAdminRoutes } from "./routes/legacy-admin"; +import { legacyArchiveRoutes } from "./routes/legacy-archive"; +import { mapTileRoutes } from "./routes/map-tiles"; +import { privateRoutes } from "./routes/private"; +import { publicRoutes } from "./routes/public"; +import { scriptRoutes } from "./routes/tracker-script"; +import { v1Routes } from "./routes/v1"; +import { wellKnownRoutes } from "./routes/well-known"; +import type { AppEnv } from "./types"; + +export const apiApp = new Hono(); + +apiApp.onError(handleHonoError); + +apiApp.route("/", healthRoutes); +apiApp.route("/", wellKnownRoutes); +apiApp.route("/", collectRoutes); +apiApp.route("/", scriptRoutes); +apiApp.route("/", adminWsRoutes); +apiApp.route("/api/auth", authRoutes); +apiApp.route("/api/admin", legacyAdminRoutes); +apiApp.route("/api/archive", legacyArchiveRoutes); +apiApp.route("/api/private", privateRoutes); +apiApp.route("/api/public", publicRoutes); +apiApp.route("/api/v1", v1Routes); +apiApp.route("/api/map-tiles", mapTileRoutes); + +export default apiApp; diff --git a/src/lib/hono/middleware/api-key.ts b/src/lib/hono/middleware/api-key.ts new file mode 100644 index 00000000..54cc54d6 --- /dev/null +++ b/src/lib/hono/middleware/api-key.ts @@ -0,0 +1,50 @@ +import type { MiddlewareHandler } from "hono"; + +import { authenticateApiKey } from "@/lib/edge/api-key-auth"; +import type { ApiKeyScope } from "@/lib/edge/api-key-store"; +import { requireScope } from "@/lib/edge/api-v1-helpers"; +import type { AppEnv } from "@/lib/hono/types"; +import { executionContext } from "@/lib/hono/utils/context"; + +export function authenticateApiKeyMiddleware(): MiddlewareHandler { + return async (c, next) => { + const principal = await authenticateApiKey( + c.req.raw, + c.env, + executionContext(c), + ); + if (principal instanceof Response) { + c.res = principal; + return principal; + } + c.set("apiPrincipal", principal); + await next(); + }; +} + +export function requireApiScopeMiddleware( + scope: ApiKeyScope, +): MiddlewareHandler { + return async (c, next) => { + let principal = c.get("apiPrincipal"); + if (!principal) { + const authenticated = await authenticateApiKey( + c.req.raw, + c.env, + executionContext(c), + ); + if (authenticated instanceof Response) { + c.res = authenticated; + return authenticated; + } + principal = authenticated; + c.set("apiPrincipal", principal); + } + const denied = requireScope(principal.scopes, scope, c.req.raw); + if (denied) { + c.res = denied; + return denied; + } + await next(); + }; +} diff --git a/src/lib/hono/middleware/body.ts b/src/lib/hono/middleware/body.ts new file mode 100644 index 00000000..19a7064e --- /dev/null +++ b/src/lib/hono/middleware/body.ts @@ -0,0 +1,23 @@ +import type { MiddlewareHandler } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; +import { + cloneRequestWithJsonBody, + readJsonRecord, +} from "@/lib/hono/utils/request"; + +export function normalizeJsonBodyMiddleware( + transform: (body: Record) => Record, +): MiddlewareHandler { + return async (c, next) => { + const rawRequest = c.req.raw as unknown as Request; + const body = await readJsonRecord(rawRequest.clone() as unknown as Request); + if (body) { + c.req.raw = cloneRequestWithJsonBody( + rawRequest, + transform(body), + ) as typeof c.req.raw; + } + await next(); + }; +} diff --git a/src/lib/hono/middleware/dashboard-cache.ts b/src/lib/hono/middleware/dashboard-cache.ts new file mode 100644 index 00000000..b2c97439 --- /dev/null +++ b/src/lib/hono/middleware/dashboard-cache.ts @@ -0,0 +1,26 @@ +import type { MiddlewareHandler } from "hono"; + +import { + type DashboardCacheOptions, + withDashboardCache, +} from "@/lib/edge/dashboard-cache"; +import type { AppEnv } from "@/lib/hono/types"; +import { executionContext, requestUrl } from "@/lib/hono/utils/context"; + +export function dashboardCacheMiddleware( + options?: DashboardCacheOptions, +): MiddlewareHandler { + return async (c, next) => { + const response = await withDashboardCache( + executionContext(c), + requestUrl(c), + async () => { + await next(); + return c.res; + }, + options, + ); + c.res = response; + return response; + }; +} diff --git a/src/lib/hono/middleware/error-boundary.ts b/src/lib/hono/middleware/error-boundary.ts new file mode 100644 index 00000000..67b5f16e --- /dev/null +++ b/src/lib/hono/middleware/error-boundary.ts @@ -0,0 +1,33 @@ +import type { MiddlewareHandler } from "hono"; +import type { Context } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; +import { internalServerError } from "@/lib/hono/utils/response"; + +export function handleHonoError(error: Error, c: Context): Response { + console.error("hono_route_unhandled_error", { + method: c.req.raw.method, + url: c.req.raw.url, + error, + }); + return internalServerError(c.req.raw, error); +} + +export function errorBoundaryMiddleware(): MiddlewareHandler { + return async (c, next) => { + try { + await next(); + } catch (error) { + if (error instanceof Response) { + c.res = error; + return error; + } + const response = handleHonoError( + error instanceof Error ? error : new Error(String(error)), + c, + ); + c.res = response; + return response; + } + }; +} diff --git a/src/lib/hono/middleware/method.ts b/src/lib/hono/middleware/method.ts new file mode 100644 index 00000000..ed1d0d3b --- /dev/null +++ b/src/lib/hono/middleware/method.ts @@ -0,0 +1,32 @@ +import type { MiddlewareHandler } from "hono"; + +import { methodNotAllowed } from "@/lib/edge/api-v1-helpers"; +import type { AppEnv } from "@/lib/hono/types"; + +export function requireMethodMiddleware( + method: string, +): MiddlewareHandler { + const allowed = method.toUpperCase(); + return async (c, next) => { + if (c.req.raw.method.toUpperCase() !== allowed) { + const response = methodNotAllowed(c.req.raw); + c.res = response; + return response; + } + await next(); + }; +} + +export function requireMethodsMiddleware( + methods: readonly string[], +): MiddlewareHandler { + const allowed = new Set(methods.map((method) => method.toUpperCase())); + return async (c, next) => { + if (!allowed.has(c.req.raw.method.toUpperCase())) { + const response = methodNotAllowed(c.req.raw); + c.res = response; + return response; + } + await next(); + }; +} diff --git a/src/lib/hono/middleware/request-id.ts b/src/lib/hono/middleware/request-id.ts new file mode 100644 index 00000000..01eae18c --- /dev/null +++ b/src/lib/hono/middleware/request-id.ts @@ -0,0 +1,12 @@ +import type { MiddlewareHandler } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; +import { getRequestId } from "@/lib/response"; + +export function requestIdMiddleware(): MiddlewareHandler { + return async (c, next) => { + const requestId = getRequestId(c.req.raw); + c.set("requestId", requestId); + await next(); + }; +} diff --git a/src/lib/hono/middleware/same-origin.ts b/src/lib/hono/middleware/same-origin.ts new file mode 100644 index 00000000..d1e7dac1 --- /dev/null +++ b/src/lib/hono/middleware/same-origin.ts @@ -0,0 +1,15 @@ +import type { MiddlewareHandler } from "hono"; + +import { requireSameOrigin } from "@/lib/edge/utils"; +import type { AppEnv } from "@/lib/hono/types"; + +export function sameOriginMiddleware(): MiddlewareHandler { + return async (c, next) => { + const error = requireSameOrigin(c.req.raw); + if (error) { + c.res = error; + return error; + } + await next(); + }; +} diff --git a/src/lib/hono/middleware/session.ts b/src/lib/hono/middleware/session.ts new file mode 100644 index 00000000..f71f5643 --- /dev/null +++ b/src/lib/hono/middleware/session.ts @@ -0,0 +1,18 @@ +import type { MiddlewareHandler } from "hono"; + +import { requireSession } from "@/lib/edge/session-auth"; +import type { AppEnv } from "@/lib/hono/types"; +import { una as unauthorized } from "@/lib/response"; + +export function requireSessionMiddleware(): MiddlewareHandler { + return async (c, next) => { + const session = await requireSession(c.req.raw, c.env); + if (!session) { + const response = unauthorized("Unauthorized", undefined, c.req.raw); + c.res = response; + return response; + } + c.set("session", session); + await next(); + }; +} diff --git a/src/lib/hono/middleware/site.ts b/src/lib/hono/middleware/site.ts new file mode 100644 index 00000000..1df4807d --- /dev/null +++ b/src/lib/hono/middleware/site.ts @@ -0,0 +1,87 @@ +import type { MiddlewareHandler } from "hono"; + +import { canAccessSiteId } from "@/lib/edge/api-key-auth"; +import { jsonError } from "@/lib/edge/api-v1-helpers"; +import { fetchPublicSite, resolvePrivateSite } from "@/lib/edge/query/core"; +import type { AppEnv, HonoApiSite } from "@/lib/hono/types"; +import { requestUrl } from "@/lib/hono/utils/context"; + +export function resolvePrivateSiteMiddleware(): MiddlewareHandler { + return async (c, next) => { + const site = await resolvePrivateSite(c.req.raw, c.env, requestUrl(c)); + if (site instanceof Response) { + c.res = site; + return site; + } + c.set("privateSite", site); + c.set("site", site); + await next(); + }; +} + +export function resolvePublicSiteMiddleware(): MiddlewareHandler { + return async (c, next) => { + const site = await fetchPublicSite(c.env, requestUrl(c)); + if (site instanceof Response) { + c.res = site; + return site; + } + c.set("publicSite", { + ...site, + slug: c.req.param("slug"), + }); + await next(); + }; +} + +export function resolveApiSiteMiddleware(): MiddlewareHandler { + return async (c, next) => { + const principal = c.get("apiPrincipal"); + const siteId = c.req.param("siteId"); + if (!principal || !siteId || !canAccessSiteId(principal, siteId)) { + const response = jsonError( + "site_not_found", + "Site not found", + 404, + undefined, + c.req.raw, + ); + c.res = response; + return response; + } + + const row = await c.env.DB.prepare( + ` + SELECT + id, + team_id AS teamId, + name, + domain, + public_enabled AS publicEnabled, + public_slug AS publicSlug, + created_at AS createdAt, + updated_at AS updatedAt + FROM sites + WHERE id=? AND team_id=? + LIMIT 1 + `, + ) + .bind(siteId, principal.teamId) + .first(); + + if (!row) { + const response = jsonError( + "site_not_found", + "Site not found", + 404, + undefined, + c.req.raw, + ); + c.res = response; + return response; + } + + c.set("apiSite", row); + await next(); + }; +} diff --git a/src/lib/hono/path-match.ts b/src/lib/hono/path-match.ts new file mode 100644 index 00000000..15c8f48d --- /dev/null +++ b/src/lib/hono/path-match.ts @@ -0,0 +1,10 @@ +export function shouldUseHono(pathname: string): boolean { + return ( + pathname.startsWith("/api/") || + pathname === "/collect" || + pathname === "/script.js" || + pathname === "/healthz" || + pathname.startsWith("/.well-known/") || + pathname === "/admin/ws" + ); +} diff --git a/src/lib/hono/routes/admin-ws.ts b/src/lib/hono/routes/admin-ws.ts new file mode 100644 index 00000000..a9bfd2b6 --- /dev/null +++ b/src/lib/hono/routes/admin-ws.ts @@ -0,0 +1,8 @@ +import { Hono } from "hono"; + +import { handleAdminWs } from "@/lib/edge/admin-ws"; +import type { AppEnv } from "@/lib/hono/types"; + +export const adminWsRoutes = new Hono(); + +adminWsRoutes.all("/admin/ws", (c) => handleAdminWs(c.req.raw, c.env)); diff --git a/src/lib/hono/routes/auth.ts b/src/lib/hono/routes/auth.ts new file mode 100644 index 00000000..338c785c --- /dev/null +++ b/src/lib/hono/routes/auth.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; + +import { + handleLegacyAuthLogin, + handleLegacyAuthLogout, +} from "@/lib/edge/legacy-auth"; +import type { AppEnv } from "@/lib/hono/types"; + +export const authRoutes = new Hono(); + +authRoutes.post("/login", (c) => handleLegacyAuthLogin(c.req.raw, c.env)); +authRoutes.post("/logout", (c) => handleLegacyAuthLogout(c.req.raw)); diff --git a/src/lib/hono/routes/collect.ts b/src/lib/hono/routes/collect.ts new file mode 100644 index 00000000..b2b2de2c --- /dev/null +++ b/src/lib/hono/routes/collect.ts @@ -0,0 +1,22 @@ +import { Hono } from "hono"; + +import { + handleCollectOptionsRequest, + handleCollectRequest, +} from "@/lib/edge/collect"; +import type { AppEnv } from "@/lib/hono/types"; + +export const collectRoutes = new Hono(); + +collectRoutes.options("/collect", (c) => + handleCollectOptionsRequest(c.req.raw), +); + +collectRoutes.post("/collect", (c) => + handleCollectRequest( + c.req.raw, + c.env, + c.executionCtx as unknown as ExecutionContext, + new URL(c.req.raw.url), + ), +); diff --git a/src/lib/hono/routes/health.ts b/src/lib/hono/routes/health.ts new file mode 100644 index 00000000..fec171a4 --- /dev/null +++ b/src/lib/hono/routes/health.ts @@ -0,0 +1,28 @@ +import { Hono } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; + +const HEALTH_HEADERS = { "content-type": "application/json" }; + +function healthResponse(env: AppEnv["Bindings"]): Response { + return new Response( + JSON.stringify({ + ok: true, + service: "insightflare", + now: new Date().toISOString(), + bindings: { + d1: Boolean(env.DB), + durableObject: Boolean(env.INGEST_DO), + r2Archive: Boolean(env.ARCHIVE_BUCKET), + }, + }), + { + status: 200, + headers: HEALTH_HEADERS, + }, + ); +} + +export const healthRoutes = new Hono(); + +healthRoutes.get("/healthz", (c) => healthResponse(c.env)); diff --git a/src/lib/hono/routes/legacy-admin.ts b/src/lib/hono/routes/legacy-admin.ts new file mode 100644 index 00000000..50156eae --- /dev/null +++ b/src/lib/hono/routes/legacy-admin.ts @@ -0,0 +1,26 @@ +import { Hono } from "hono"; + +import { + handleLegacyAdminMember, + handleLegacyAdminProfile, + handleLegacyAdminSite, + handleLegacyAdminSiteConfig, + handleLegacyAdminTeam, + handleLegacyAdminUser, +} from "@/lib/edge/legacy-admin"; +import type { AppEnv } from "@/lib/hono/types"; + +export const legacyAdminRoutes = new Hono(); + +legacyAdminRoutes.post("/user", (c) => handleLegacyAdminUser(c.req.raw, c.env)); +legacyAdminRoutes.post("/team", (c) => handleLegacyAdminTeam(c.req.raw, c.env)); +legacyAdminRoutes.post("/site", (c) => handleLegacyAdminSite(c.req.raw, c.env)); +legacyAdminRoutes.post("/member", (c) => + handleLegacyAdminMember(c.req.raw, c.env), +); +legacyAdminRoutes.post("/profile", (c) => + handleLegacyAdminProfile(c.req.raw, c.env), +); +legacyAdminRoutes.post("/site-config", (c) => + handleLegacyAdminSiteConfig(c.req.raw, c.env), +); diff --git a/src/lib/hono/routes/legacy-archive.ts b/src/lib/hono/routes/legacy-archive.ts new file mode 100644 index 00000000..1a5422db --- /dev/null +++ b/src/lib/hono/routes/legacy-archive.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; + +import { + handleLegacyArchiveFile, + handleLegacyArchiveManifest, +} from "@/lib/edge/legacy-archive"; +import type { AppEnv } from "@/lib/hono/types"; + +export const legacyArchiveRoutes = new Hono(); + +legacyArchiveRoutes.get("/manifest", (c) => + handleLegacyArchiveManifest(c.req.raw, c.env), +); +legacyArchiveRoutes.get("/file", (c) => + handleLegacyArchiveFile(c.req.raw, c.env), +); +legacyArchiveRoutes.on("HEAD", "/file", (c) => + handleLegacyArchiveFile(c.req.raw, c.env), +); diff --git a/src/lib/hono/routes/map-tiles.ts b/src/lib/hono/routes/map-tiles.ts new file mode 100644 index 00000000..febf6d73 --- /dev/null +++ b/src/lib/hono/routes/map-tiles.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; + +import { handleMapTileRequest } from "@/lib/edge/map-tiles"; +import type { AppEnv } from "@/lib/hono/types"; + +export const mapTileRoutes = new Hono(); + +mapTileRoutes.get("/:z/:x/:y", (c) => + handleMapTileRequest(c.req.raw, { + z: c.req.param("z"), + x: c.req.param("x"), + y: c.req.param("y"), + }), +); diff --git a/src/lib/hono/routes/private/admin.ts b/src/lib/hono/routes/private/admin.ts new file mode 100644 index 00000000..348c1e86 --- /dev/null +++ b/src/lib/hono/routes/private/admin.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; + +import { handleApiKeysAdmin } from "@/lib/edge/admin-api-keys"; +import { requireActor } from "@/lib/edge/admin-auth"; +import { nf } from "@/lib/edge/admin-response"; +import { handleScheduledTasksAdmin } from "@/lib/edge/admin-scheduled-tasks"; +import { + handleScriptSnippetAdmin, + handleSiteConfigAdmin, + handleSitesAdmin, +} from "@/lib/edge/admin-sites"; +import { + handleDoDiagnosticAdmin, + handleSystemPerformanceAdmin, +} from "@/lib/edge/admin-system"; +import { handleMembersAdmin, handleTeamsAdmin } from "@/lib/edge/admin-teams"; +import { + handleAuthLoginAdmin, + handleAuthMeAdmin, + handleProfileAdmin, + handleUsersAdmin, +} from "@/lib/edge/admin-users"; +import type { AppEnv } from "@/lib/hono/types"; +import { requestUrl } from "@/lib/hono/utils/context"; + +export const privateAdminRoutes = new Hono(); + +privateAdminRoutes.all("/auth/login", (c) => + handleAuthLoginAdmin(c.req.raw, c.env), +); +privateAdminRoutes.all("/auth/me", (c) => handleAuthMeAdmin(c.req.raw, c.env)); +privateAdminRoutes.all("/users", (c) => handleUsersAdmin(c.req.raw, c.env)); +privateAdminRoutes.all("/profile", (c) => handleProfileAdmin(c.req.raw, c.env)); +privateAdminRoutes.all("/teams", (c) => handleTeamsAdmin(c.req.raw, c.env)); +privateAdminRoutes.all("/sites", (c) => + handleSitesAdmin(c.req.raw, c.env, requestUrl(c)), +); +privateAdminRoutes.all("/members", (c) => + handleMembersAdmin(c.req.raw, c.env, requestUrl(c)), +); +privateAdminRoutes.all("/site-config", (c) => + handleSiteConfigAdmin(c.req.raw, c.env, requestUrl(c)), +); +privateAdminRoutes.all("/script-snippet", (c) => + handleScriptSnippetAdmin(c.req.raw, c.env, requestUrl(c)), +); +privateAdminRoutes.all("/api-keys", (c) => + handleApiKeysAdmin(c.req.raw, c.env, requestUrl(c)), +); +privateAdminRoutes.all("/system-performance", (c) => + handleSystemPerformanceAdmin(c.req.raw, c.env, requestUrl(c), requireActor), +); +privateAdminRoutes.all("/scheduled-tasks", (c) => + handleScheduledTasksAdmin(c.req.raw, c.env, requestUrl(c), requireActor), +); +privateAdminRoutes.all("/do-diagnostic", (c) => + handleDoDiagnosticAdmin(c.req.raw, c.env, requestUrl(c), requireActor), +); +privateAdminRoutes.all("/*", () => nf()); diff --git a/src/lib/hono/routes/private/archive.ts b/src/lib/hono/routes/private/archive.ts new file mode 100644 index 00000000..22303014 --- /dev/null +++ b/src/lib/hono/routes/private/archive.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; + +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; +import type { AppEnv } from "@/lib/hono/types"; +import { requestUrl } from "@/lib/hono/utils/context"; +import { nf as notFound } from "@/lib/response"; + +export const privateArchiveRoutes = new Hono(); + +privateArchiveRoutes.all("/manifest", (c) => + handlePrivateArchiveManifest(c.req.raw, c.env, requestUrl(c)), +); +privateArchiveRoutes.all("/file", (c) => + handlePrivateArchiveFile(c.req.raw, c.env, requestUrl(c)), +); +privateArchiveRoutes.all("/*", () => notFound()); diff --git a/src/lib/hono/routes/private/index.ts b/src/lib/hono/routes/private/index.ts new file mode 100644 index 00000000..18efd42d --- /dev/null +++ b/src/lib/hono/routes/private/index.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; + +import { privateAdminRoutes } from "./admin"; +import { privateArchiveRoutes } from "./archive"; +import { privateQueryRoutes } from "./query"; + +export const privateRoutes = new Hono(); + +privateRoutes.route("/admin", privateAdminRoutes); +privateRoutes.route("/archive", privateArchiveRoutes); +privateRoutes.route("/", privateQueryRoutes); diff --git a/src/lib/hono/routes/private/query.ts b/src/lib/hono/routes/private/query.ts new file mode 100644 index 00000000..defb1db4 --- /dev/null +++ b/src/lib/hono/routes/private/query.ts @@ -0,0 +1,76 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; + +import { notAllowed } from "@/lib/edge/query/core"; +import { + DASHBOARD_QUERY_PATHS, + dispatchQueryRoute, +} from "@/lib/edge/query/router"; +import { handleTeamDashboard } from "@/lib/edge/query/team"; +import { dashboardCacheMiddleware } from "@/lib/hono/middleware/dashboard-cache"; +import { + requireMethodMiddleware, + requireMethodsMiddleware, +} from "@/lib/hono/middleware/method"; +import { resolvePrivateSiteMiddleware } from "@/lib/hono/middleware/site"; +import type { AppEnv } from "@/lib/hono/types"; +import { requestUrl } from "@/lib/hono/utils/context"; + +const FUNNEL_PATH = "funnels"; +const TEAM_DASHBOARD_PATH = "team-dashboard"; + +function privateQuery(pathname: string) { + return (c: Context) => { + const site = c.get("privateSite"); + if (!site) { + throw new Error("private site context missing"); + } + return dispatchQueryRoute( + c.env, + site.id, + pathname, + requestUrl(c), + { publicMode: false }, + c.req.raw, + ); + }; +} + +export const privateQueryRoutes = new Hono(); + +privateQueryRoutes.all("/team-dashboard", (c) => { + if (c.req.raw.method !== "GET") return notAllowed(); + return handleTeamDashboard(c.req.raw, c.env, requestUrl(c)); +}); + +privateQueryRoutes.use( + `/${FUNNEL_PATH}`, + requireMethodsMiddleware(["GET", "POST", "DELETE"]), +); +privateQueryRoutes.all( + `/${FUNNEL_PATH}`, + resolvePrivateSiteMiddleware(), + privateQuery(FUNNEL_PATH), +); + +for (const path of DASHBOARD_QUERY_PATHS) { + if (path === FUNNEL_PATH || path === TEAM_DASHBOARD_PATH) continue; + privateQueryRoutes.use(`/${path}`, requireMethodMiddleware("GET")); + privateQueryRoutes.all( + `/${path}`, + resolvePrivateSiteMiddleware(), + dashboardCacheMiddleware(), + privateQuery(path), + ); +} + +privateQueryRoutes.use("/*", requireMethodMiddleware("GET")); +privateQueryRoutes.all( + "/*", + resolvePrivateSiteMiddleware(), + dashboardCacheMiddleware(), + (c) => { + const pathname = requestUrl(c).pathname.replace(/^\/api\/private\//, ""); + return privateQuery(pathname)(c); + }, +); diff --git a/src/lib/hono/routes/public/index.ts b/src/lib/hono/routes/public/index.ts new file mode 100644 index 00000000..4b8413e2 --- /dev/null +++ b/src/lib/hono/routes/public/index.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; + +import { publicQueryRoutes } from "./query"; + +export const publicRoutes = new Hono(); + +publicRoutes.route("/", publicQueryRoutes); diff --git a/src/lib/hono/routes/public/query.ts b/src/lib/hono/routes/public/query.ts new file mode 100644 index 00000000..ec665e78 --- /dev/null +++ b/src/lib/hono/routes/public/query.ts @@ -0,0 +1,81 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; + +import { PUBLIC_QUERY_CACHE_OPTIONS } from "@/lib/edge/dashboard-cache"; +import { jsonResponse } from "@/lib/edge/query/core"; +import { + dispatchQueryRoute, + PUBLIC_QUERY_PATHS, +} from "@/lib/edge/query/router"; +import { dashboardCacheMiddleware } from "@/lib/hono/middleware/dashboard-cache"; +import { requireMethodMiddleware } from "@/lib/hono/middleware/method"; +import { resolvePublicSiteMiddleware } from "@/lib/hono/middleware/site"; +import type { AppEnv } from "@/lib/hono/types"; +import { requestUrl } from "@/lib/hono/utils/context"; + +function publicSlug(c: Context): string { + const segments = requestUrl(c).pathname.split("/").filter(Boolean); + return decodeURIComponent(segments[2] || ""); +} + +function publicQuery(pathname: string) { + return (c: Context) => { + const site = c.get("publicSite"); + if (!site) { + throw new Error("public site context missing"); + } + return dispatchQueryRoute( + c.env, + site.id, + pathname, + requestUrl(c), + { publicMode: true }, + c.req.raw, + ); + }; +} + +export const publicQueryRoutes = new Hono(); + +publicQueryRoutes.use("/:slug/*", requireMethodMiddleware("GET")); + +publicQueryRoutes.get( + "/:slug/site", + resolvePublicSiteMiddleware(), + dashboardCacheMiddleware(PUBLIC_QUERY_CACHE_OPTIONS), + (c) => { + const site = c.get("publicSite"); + if (!site) { + throw new Error("public site context missing"); + } + return jsonResponse({ + ok: true, + data: { + slug: publicSlug(c), + name: site.name, + domain: site.domain, + id: site.id, + }, + }); + }, +); + +for (const path of PUBLIC_QUERY_PATHS) { + publicQueryRoutes.get( + `/:slug/${path}`, + resolvePublicSiteMiddleware(), + dashboardCacheMiddleware(PUBLIC_QUERY_CACHE_OPTIONS), + publicQuery(path), + ); +} + +publicQueryRoutes.all( + "/:slug/*", + resolvePublicSiteMiddleware(), + dashboardCacheMiddleware(PUBLIC_QUERY_CACHE_OPTIONS), + (c) => { + const segments = requestUrl(c).pathname.split("/").filter(Boolean); + const pathname = segments.slice(3).join("/"); + return publicQuery(pathname)(c); + }, +); diff --git a/src/lib/hono/routes/tracker-script.ts b/src/lib/hono/routes/tracker-script.ts new file mode 100644 index 00000000..ecc314ef --- /dev/null +++ b/src/lib/hono/routes/tracker-script.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; + +import { handleTrackerScriptRequest } from "@/lib/edge/script-endpoint"; +import type { AppEnv } from "@/lib/hono/types"; + +export const scriptRoutes = new Hono(); + +scriptRoutes.get("/script.js", (c) => + handleTrackerScriptRequest(c.req.raw, c.env), +); diff --git a/src/lib/hono/routes/v1/index.ts b/src/lib/hono/routes/v1/index.ts new file mode 100644 index 00000000..9557e539 --- /dev/null +++ b/src/lib/hono/routes/v1/index.ts @@ -0,0 +1,317 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; + +import { + apiV1Segments, + handleAnalytics, + handleBatch, + handleCapabilities, + handleEvents, + handleFunnels, + handleJourneys, + handlePerformance, + handlePrivacy, + handleRealtime, + handleRoot, + handleSharing, + handleSiteResource, + handleSitesCollection, + handleTeam, + handleToken, + handleTokenCheck, + handleTracking, + handleTrackingScript, +} from "@/lib/edge/api-v1"; +import { jsonError } from "@/lib/edge/api-v1-helpers"; +import { authenticateApiKeyMiddleware } from "@/lib/hono/middleware/api-key"; +import type { AppEnv } from "@/lib/hono/types"; +import { executionContext, requestUrl } from "@/lib/hono/utils/context"; + +function principal(c: Context) { + const value = c.get("apiPrincipal"); + if (!value) { + throw new Error("api principal context missing"); + } + return value; +} + +function path(c: Context): string[] { + return apiV1Segments(requestUrl(c)); +} + +function resourceNotFound(c: Context) { + return jsonError( + "resource_not_found", + "Resource not found", + 404, + undefined, + c.req.raw, + ); +} + +function withSiteId( + c: Context, + handler: (siteId: string, routePath: string[]) => Promise, +) { + const siteId = c.req.param("siteId"); + if (!siteId) return resourceNotFound(c); + return handler(siteId, path(c)); +} + +function mountedV1Request(request: Request, url: URL): Request { + const mountedUrl = new URL(url); + mountedUrl.pathname = mountedUrl.pathname.replace(/^\/api\/v1\/?/, "/"); + if (!mountedUrl.pathname.startsWith("/")) { + mountedUrl.pathname = `/${mountedUrl.pathname}`; + } + return new Request(mountedUrl, { + method: request.method, + headers: request.headers, + body: request.body, + }); +} + +async function dispatchBatchSubrequest( + request: Request, + env: AppEnv["Bindings"], + url: URL, + ctx: ExecutionContext, +): Promise { + return v1Routes.fetch(mountedV1Request(request, url), env, ctx); +} + +export const v1Routes = new Hono(); + +v1Routes.get("/", (c) => handleRoot(c.req.raw)); +v1Routes.use("/*", authenticateApiKeyMiddleware()); + +v1Routes.all("/token", (c) => handleToken(c.req.raw, c.env, principal(c))); +v1Routes.all("/token/check", (c) => handleTokenCheck(c.req.raw, principal(c))); +v1Routes.all("/capabilities", (c) => + handleCapabilities(c.req.raw, principal(c)), +); +v1Routes.all("/team", (c) => + handleTeam(c.req.raw, c.env, requestUrl(c), principal(c), path(c)), +); +v1Routes.all("/team/*", (c) => + handleTeam(c.req.raw, c.env, requestUrl(c), principal(c), path(c)), +); +v1Routes.all("/batch", (c) => + handleBatch( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + (request, env, url) => + dispatchBatchSubrequest(request, env, url, executionContext(c)), + ), +); +v1Routes.all("/sites", (c) => + handleSitesCollection(c.req.raw, c.env, principal(c)), +); +v1Routes.all("/sites/:siteId", (c) => + withSiteId(c, (siteId) => + handleSiteResource(c.req.raw, c.env, principal(c), siteId), + ), +); +v1Routes.all("/sites/:siteId/tracking", (c) => + withSiteId(c, (siteId) => + handleTracking(c.req.raw, c.env, principal(c), siteId), + ), +); +v1Routes.all("/sites/:siteId/tracking/script", (c) => + withSiteId(c, (siteId) => + handleTrackingScript(c.req.raw, c.env, requestUrl(c), principal(c), siteId), + ), +); +v1Routes.all("/sites/:siteId/privacy", (c) => + withSiteId(c, (siteId) => + handlePrivacy(c.req.raw, c.env, principal(c), siteId), + ), +); +v1Routes.all("/sites/:siteId/sharing", (c) => + withSiteId(c, (siteId) => + handleSharing(c.req.raw, c.env, principal(c), siteId), + ), +); +v1Routes.all("/sites/:siteId/analytics/*", (c) => + withSiteId(c, (siteId, routePath) => + handleAnalytics( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/event-types", (c) => + withSiteId(c, (siteId, routePath) => + handleEvents( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/events", (c) => + withSiteId(c, (siteId, routePath) => + handleEvents( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/events/*", (c) => + withSiteId(c, (siteId, routePath) => + handleEvents( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/event-fields", (c) => + withSiteId(c, (siteId, routePath) => + handleEvents( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/visitors", (c) => + withSiteId(c, (siteId, routePath) => + handleJourneys( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/visitors/:visitorId", (c) => + withSiteId(c, (siteId, routePath) => + handleJourneys( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/sessions", (c) => + withSiteId(c, (siteId, routePath) => + handleJourneys( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/sessions/:sessionId", (c) => + withSiteId(c, (siteId, routePath) => + handleJourneys( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/funnels", (c) => + withSiteId(c, (siteId, routePath) => + handleFunnels( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/funnels/*", (c) => + withSiteId(c, (siteId, routePath) => + handleFunnels( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/performance", (c) => + withSiteId(c, (siteId, routePath) => + handlePerformance( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/performance/*", (c) => + withSiteId(c, (siteId, routePath) => + handlePerformance( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/realtime", (c) => + withSiteId(c, (siteId, routePath) => + handleRealtime( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/sites/:siteId/realtime/*", (c) => + withSiteId(c, (siteId, routePath) => + handleRealtime( + c.req.raw, + c.env, + requestUrl(c), + principal(c), + siteId, + routePath, + ), + ), +); +v1Routes.all("/*", resourceNotFound); diff --git a/src/lib/hono/routes/well-known.ts b/src/lib/hono/routes/well-known.ts new file mode 100644 index 00000000..7cab1e78 --- /dev/null +++ b/src/lib/hono/routes/well-known.ts @@ -0,0 +1,103 @@ +import { Hono } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; + +import openapiSpec from "../../../../docs/openapi.json"; +import skillsSpec from "../../../../docs/skills.json"; + +const JSON_HEADERS = { + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=3600, s-maxage=3600", + "access-control-allow-origin": "*", +}; + +const TEXT_HEADERS = { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=3600, s-maxage=3600", + "access-control-allow-origin": "*", +}; + +const SECURITY_TXT = `Contact: mailto:contact@insightflare.net +Expires: 2027-06-25T00:00:00.000Z +Preferred-Languages: en, zh +Acknowledgments: https://github.com/RavelloH/InsightFlare +Policy: https://github.com/RavelloH/InsightFlare/blob/main/SECURITY.md +`; + +function getBaseUrl(request: Request): string { + const host = + request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + const proto = request.headers.get("x-forwarded-proto") ?? "https"; + if (!host) return new URL(request.url).origin; + return `${proto}://${host}`; +} + +export const wellKnownRoutes = new Hono(); + +wellKnownRoutes.get("/.well-known/openapi.json", (c) => { + const baseUrl = getBaseUrl(c.req.raw); + const dynamicSpec = { + ...openapiSpec, + servers: openapiSpec.servers.map( + (server: { url: string; description: string }) => ({ + ...server, + url: baseUrl, + }), + ), + }; + return new Response(JSON.stringify(dynamicSpec), { + status: 200, + headers: JSON_HEADERS, + }); +}); + +wellKnownRoutes.on( + "HEAD", + "/.well-known/openapi.json", + () => new Response(null, { status: 200, headers: JSON_HEADERS }), +); + +wellKnownRoutes.get("/.well-known/skills.json", (c) => { + const body = JSON.stringify(skillsSpec).replaceAll( + "${baseUrl}", + getBaseUrl(c.req.raw), + ); + return new Response(body, { status: 200, headers: JSON_HEADERS }); +}); + +wellKnownRoutes.on( + "HEAD", + "/.well-known/skills.json", + () => new Response(null, { status: 200, headers: JSON_HEADERS }), +); + +wellKnownRoutes.get( + "/.well-known/security.txt", + () => new Response(SECURITY_TXT, { status: 200, headers: TEXT_HEADERS }), +); + +wellKnownRoutes.on( + "HEAD", + "/.well-known/security.txt", + () => new Response(null, { status: 200, headers: TEXT_HEADERS }), +); + +wellKnownRoutes.get("/.well-known/change-password", (c) => + Response.redirect(`${getBaseUrl(c.req.raw)}/app`, 302), +); + +wellKnownRoutes.on( + "HEAD", + "/.well-known/change-password", + () => new Response(null, { status: 200 }), +); + +wellKnownRoutes.get("/.well-known/health", (c) => + Response.redirect(`${getBaseUrl(c.req.raw)}/healthz`, 302), +); + +wellKnownRoutes.on( + "HEAD", + "/.well-known/health", + () => new Response(null, { status: 200 }), +); diff --git a/src/lib/hono/types.ts b/src/lib/hono/types.ts new file mode 100644 index 00000000..8c34528e --- /dev/null +++ b/src/lib/hono/types.ts @@ -0,0 +1,41 @@ +import type { ApiKeyPrincipal } from "@/lib/edge/api-key-auth"; +import type { EdgeSessionClaims } from "@/lib/edge/session-auth"; +import type { Env as EdgeEnv } from "@/lib/edge/types"; + +export type HonoBindings = EdgeEnv; + +export interface HonoSite { + id: string; + name?: string; + domain?: string; +} + +export interface HonoPublicSite extends HonoSite { + slug?: string; +} + +export interface HonoApiSite { + id: string; + teamId: string; + name: string; + domain: string; + publicEnabled: number; + publicSlug: string | null; + createdAt: number; + updatedAt: number; +} + +export type HonoVariables = { + requestId: string; + session?: EdgeSessionClaims; + privateSite?: HonoSite; + site?: HonoSite; + publicSite?: HonoPublicSite; + apiPrincipal?: ApiKeyPrincipal; + apiSite?: HonoApiSite; +}; + +export type AppEnv = { + Bindings: HonoBindings; + Variables: HonoVariables; +}; diff --git a/src/lib/hono/utils/context.ts b/src/lib/hono/utils/context.ts new file mode 100644 index 00000000..3f48ad01 --- /dev/null +++ b/src/lib/hono/utils/context.ts @@ -0,0 +1,15 @@ +import type { Context } from "hono"; + +import type { AppEnv } from "@/lib/hono/types"; + +export function executionContext(c: Context): ExecutionContext { + return c.executionCtx as unknown as ExecutionContext; +} + +export function requestUrl(c: Context): URL { + return new URL(c.req.raw.url); +} + +export function responseContext(c: Context): { requestId: string } { + return { requestId: c.get("requestId") }; +} diff --git a/src/lib/hono/utils/request.ts b/src/lib/hono/utils/request.ts new file mode 100644 index 00000000..4e51ccc0 --- /dev/null +++ b/src/lib/hono/utils/request.ts @@ -0,0 +1,26 @@ +export async function readJsonRecord( + request: Request, +): Promise | null> { + try { + const parsed = (await request.json()) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} + +export function cloneRequestWithJsonBody( + request: Request, + body: Record, +): Request { + const headers = new Headers(request.headers); + headers.set("content-type", "application/json"); + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(body), + }); +} diff --git a/src/lib/hono/utils/response.ts b/src/lib/hono/utils/response.ts new file mode 100644 index 00000000..6f2b62a5 --- /dev/null +++ b/src/lib/hono/utils/response.ts @@ -0,0 +1,14 @@ +import { errorResponse } from "@/lib/response"; + +export function internalServerError( + request: Request, + error: unknown, +): Response { + const message = error instanceof Error ? error.message : String(error); + return errorResponse( + request, + 500, + "internal_server_error", + message || "Internal Server Error", + ); +} diff --git a/src/lib/i18n/messages-types-analytics.ts b/src/lib/i18n/messages-types-analytics.ts index 91bf4d70..792a0c6a 100644 --- a/src/lib/i18n/messages-types-analytics.ts +++ b/src/lib/i18n/messages-types-analytics.ts @@ -511,4 +511,8 @@ export interface AppAnalyticsMessages { metricValueColumn: string; statusColumn: string; }; + share: { + title: string; + poweredBy: string; + }; } diff --git a/src/lib/i18n/messages-types-management.ts b/src/lib/i18n/messages-types-management.ts index ad41dcfb..d6569ccb 100644 --- a/src/lib/i18n/messages-types-management.ts +++ b/src/lib/i18n/messages-types-management.ts @@ -6,7 +6,16 @@ export interface AppManagementMessages { editSubtitle: string; nameLabel: string; domainLabel: string; + publicSharingTitle: string; + publicSharingSubtitle: string; + publicEnabledLabel: string; publicSlugLabel: string; + publicSlugPlaceholder: string; + publicSlugHint: string; + publicLinkLabel: string; + publicLinkHint: string; + publicDisabledHint: string; + copiedLink: string; trackingStrengthGroupTitle: string; trackingStrengthDescription: string; trackingStrengthLabel: string; @@ -205,6 +214,21 @@ export interface AppManagementMessages { title: string; subtitle: string; empty: string; + enabled: string; + disabled: string; + disabledHint: string; + viewSettings: string; + publicUrl: string; + copyLink: string; + linkCopied: string; + noSites: string; + columns: { + site: string; + domain: string; + publicUrl: string; + status: string; + action: string; + }; }; apiKeys: { title: string; diff --git a/src/lib/realtime/demo-site-profiles.ts b/src/lib/realtime/demo-site-profiles.ts index f1551d39..dea05710 100644 --- a/src/lib/realtime/demo-site-profiles.ts +++ b/src/lib/realtime/demo-site-profiles.ts @@ -27,8 +27,36 @@ export const DEMO_SITE_PROFILES: DemoSiteProfile[] = [ ...DEMO_SITE_PROFILES_PART_3, ]; +function safeDemoSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function demoSitePublicSlug(profile: DemoSiteProfile): string { + return ( + safeDemoSlug(profile.domain || profile.name || profile.id) || + profile.id.slice(0, 8) + ); +} + export function findSiteProfile(siteId: string): DemoSiteProfile { return ( DEMO_SITE_PROFILES.find((s) => s.id === siteId) ?? DEMO_SITE_PROFILES[0] ); } + +export function findSiteProfileByPublicSlug( + slug: string, +): DemoSiteProfile | null { + const normalized = safeDemoSlug(decodeURIComponent(slug)); + return ( + DEMO_SITE_PROFILES.find( + (profile) => + demoSitePublicSlug(profile) === normalized || + safeDemoSlug(profile.id) === normalized, + ) ?? null + ); +} diff --git a/src/lib/realtime/mock.ts b/src/lib/realtime/mock.ts index ced9ccf3..d891c00a 100644 --- a/src/lib/realtime/mock.ts +++ b/src/lib/realtime/mock.ts @@ -1,4 +1,5 @@ import { normalizeTimeZone } from "@/lib/dashboard/time-zone"; +import { findSiteProfileByPublicSlug } from "@/lib/realtime/demo-site-profiles"; import { generateDemoDoDiagnostic, generateDemoScheduledTasks, @@ -84,7 +85,13 @@ export function handleDemoRequest(options: { body?: unknown; }): unknown { const { path, method = "GET", params = {} } = options; - const siteId = String(params.siteId || "demo-site-001"); + const publicRouteMatch = path.match(/\/api\/public\/([^/]+)\//); + const publicSiteProfile = publicRouteMatch + ? findSiteProfileByPublicSlug(publicRouteMatch[1] || "") + : null; + const siteId = String( + params.siteId || publicSiteProfile?.id || "demo-site-001", + ); const teamId = String(params.teamId || ""); // Write operations → read-only stub @@ -151,6 +158,38 @@ export function handleDemoRequest(options: { }, }; } + if (path.includes("/admin/site")) { + const body = + options.body && typeof options.body === "object" ? options.body : {}; + const siteBody = body as { + siteId?: unknown; + teamId?: unknown; + name?: unknown; + domain?: unknown; + publicEnabled?: unknown; + publicSlug?: unknown; + }; + const existing = + getDemoSites(String(siteBody.teamId || getDemoTeams()[0].id))[0] || + getDemoSites(getDemoTeams()[0].id)[0]; + return { + ok: true, + data: { + ...existing, + id: String(siteBody.siteId || existing.id), + name: String(siteBody.name ?? existing.name), + domain: String(siteBody.domain ?? existing.domain), + publicEnabled: + typeof siteBody.publicEnabled === "boolean" + ? siteBody.publicEnabled + : existing.publicEnabled, + publicSlug: + typeof siteBody.publicSlug === "string" + ? siteBody.publicSlug + : existing.publicSlug, + }, + }; + } // Generic write → return empty success return { ok: true, data: {} }; } @@ -189,6 +228,22 @@ export function handleDemoRequest(options: { return generateDemoDoDiagnostic(); } + const publicSiteMatch = path.match(/\/api\/public\/([^/]+)\/site$/); + if (publicSiteMatch) { + const slug = decodeURIComponent(publicSiteMatch[1] || "demo-site"); + const profile = publicSiteProfile ?? findSiteProfileByPublicSlug(slug); + if (!profile) return DEMO_NOT_FOUND_RESPONSE; + return { + ok: true, + data: { + id: profile.id, + slug, + name: profile.name, + domain: profile.domain, + }, + }; + } + // Analytics query routes if (path.includes("/filter-options")) { return generateDemoFilterOptions(siteId, params); @@ -372,11 +427,68 @@ export function handleDemoRequest(options: { // Public routes — delegate to same generators const publicMatch = path.match(/\/api\/public\/[^/]+\/(.*)/); if (publicMatch) { + if (!publicSiteProfile) return DEMO_NOT_FOUND_RESPONSE; const subPath = publicMatch[1]; if (subPath === "overview") return generateDemoOverview(siteId, params); if (subPath === "trend") return generateDemoTrend(siteId, params); if (subPath === "pages") return generateDemoPages(siteId, params); if (subPath === "referrers") return generateDemoReferrers(siteId, params); + if (subPath === "performance") + return generateDemoPerformance(siteId, params); + if (subPath === "countries") + return generateDemoDimension(siteId, "countries", params); + if (subPath === "filter-options") + return generateDemoFilterOptions(siteId, params); + if (subPath === "overview-geo-points") + return generateDemoGeoPoints(siteId, params); + if (subPath.startsWith("overview-client-")) { + if (subPath === "overview-client-browser") { + return generateDemoOverviewClientTab(siteId, params, "browser"); + } + if (subPath === "overview-client-os-version") { + return generateDemoOverviewClientTab(siteId, params, "osVersion"); + } + if (subPath === "overview-client-device-type") { + return generateDemoOverviewClientTab(siteId, params, "deviceType"); + } + if (subPath === "overview-client-language") { + return generateDemoOverviewClientTab(siteId, params, "language"); + } + if (subPath === "overview-client-screen-size") { + return generateDemoOverviewClientTab(siteId, params, "screenSize"); + } + } + if (subPath.startsWith("overview-geo-")) { + const tab = subPath.replace("overview-geo-", ""); + if ( + tab === "country" || + tab === "region" || + tab === "city" || + tab === "continent" || + tab === "timezone" || + tab === "organization" + ) { + return generateDemoOverviewGeoTab(siteId, params, tab); + } + } + if (subPath === "browser-trend") + return generateDemoBrowserTrend(siteId, params); + if (subPath === "browser-engine-trend") + return generateDemoBrowserEngineTrend(siteId, params); + if (subPath === "browser-version-breakdown") + return generateDemoBrowserVersionBreakdown(siteId, params); + if (subPath === "browser-cross-breakdown") + return generateDemoBrowserCrossBreakdown(siteId, params); + if (subPath === "browser-radar") + return generateDemoBrowserRadar(siteId, params); + if (subPath === "referrer-radar") + return generateDemoReferrerRadar(siteId, params); + if (subPath === "referrer-dimension-trend") + return generateDemoReferrerTrend(siteId, params); + if (subPath === "client-dimension-trend") + return generateDemoClientDimensionTrend(siteId, params); + if (subPath === "client-cross-breakdown") + return generateDemoClientCrossBreakdown(siteId, params); return DEMO_NOT_FOUND_RESPONSE; } diff --git a/src/lib/realtime/mock/admin.ts b/src/lib/realtime/mock/admin.ts index e497db4c..046ad9c5 100644 --- a/src/lib/realtime/mock/admin.ts +++ b/src/lib/realtime/mock/admin.ts @@ -1,6 +1,7 @@ import { DEMO_SITE_PROFILES, DEMO_TEAMS, + demoSitePublicSlug, } from "@/lib/realtime/demo-site-profiles"; import { fnv1a, mulberry32, sFloat, sInt } from "@/lib/realtime/demo-utils"; import { integrateViews } from "@/lib/realtime/mock/site-curves"; @@ -66,8 +67,8 @@ export function getDemoSites(teamId: string) { name: s.name, domain: s.domain, iconPath: s.iconPath, - publicEnabled: 0, - publicSlug: null, + publicEnabled: true, + publicSlug: demoSitePublicSlug(s), createdAt: now - 180 * 24 * 3600 * 1000, updatedAt: now - sInt(mulberry32(fnv1a(s.id)), 1, 14) * 24 * 3600 * 1000, })); diff --git a/src/lib/realtime/mock/team-dashboard.ts b/src/lib/realtime/mock/team-dashboard.ts index ad67503c..8b9f8693 100644 --- a/src/lib/realtime/mock/team-dashboard.ts +++ b/src/lib/realtime/mock/team-dashboard.ts @@ -9,6 +9,7 @@ import { import { DEMO_SITE_PROFILES, type DemoSiteProfile, + demoSitePublicSlug, findSiteProfile, } from "@/lib/realtime/demo-site-profiles"; import { @@ -207,8 +208,8 @@ export function generateDemoTeamDashboard( name: site.name, domain: site.domain, iconPath: site.iconPath, - publicEnabled: 0, - publicSlug: null, + publicEnabled: true, + publicSlug: demoSitePublicSlug(site), createdAt: now - 180 * 24 * 3600 * 1000, updatedAt: now - sInt(mulberry32(fnv1a(site.id)), 1, 14) * 24 * 3600 * 1000, diff --git a/src/lib/realtime/mock/utm-overview.ts b/src/lib/realtime/mock/utm-overview.ts index 7654e6c8..f0695c3c 100644 --- a/src/lib/realtime/mock/utm-overview.ts +++ b/src/lib/realtime/mock/utm-overview.ts @@ -378,17 +378,51 @@ export function generateDemoGeoPoints( ) : []; + // Aggregate points by rounded coordinates (3 decimal places, ~110m precision) + const aggregatedPoints = new Map< + string, + { + latitude: number; + longitude: number; + timestampMs: number; + country: string; + region: string; + regionCode: string; + city: string; + pointCount: number; + } + >(); + + for (const visit of orderedVisits) { + const latBucket = Math.round(visit.latitude * 1000) / 1000; + const lonBucket = Math.round(visit.longitude * 1000) / 1000; + const key = `${latBucket}:${lonBucket}:${visit.country}:${visit.region}:${visit.regionCode}:${visit.city}`; + + const existing = aggregatedPoints.get(key); + if (existing) { + existing.pointCount += 1; + existing.timestampMs = Math.max(existing.timestampMs, visit.startedAt); + } else { + aggregatedPoints.set(key, { + latitude: latBucket, + longitude: lonBucket, + timestampMs: visit.startedAt, + country: visit.country, + region: visit.region, + regionCode: visit.regionCode, + city: visit.city, + pointCount: 1, + }); + } + } + + const sortedAggregated = [...aggregatedPoints.values()].sort( + (left, right) => right.timestampMs - left.timestampMs, + ); + return { ok: true, - data: orderedVisits.slice(0, limit).map((visit) => ({ - latitude: visit.latitude, - longitude: visit.longitude, - timestampMs: visit.startedAt, - country: visit.country, - region: visit.region, - regionCode: visit.regionCode, - city: visit.city, - })), + data: sortedAggregated.slice(0, limit), countryCounts, regionCounts, cityCounts, diff --git a/src/schemas/__tests__/analytics.test.ts b/src/schemas/__tests__/analytics.test.ts index a9d2695d..77b652ad 100644 --- a/src/schemas/__tests__/analytics.test.ts +++ b/src/schemas/__tests__/analytics.test.ts @@ -469,22 +469,48 @@ describe("BatchInputSchema", () => { it("accepts valid batch input", () => { expect( BatchInputSchema.safeParse({ - from: 1700000000000, - to: 1700086400000, - queries: [{ queryName: "overview" }], + requests: [ + { + id: "overview", + method: "GET", + path: "/api/v1/sites/site-1/analytics/overview", + query: { preset: "last_7_days" }, + }, + ], }).success, ).toBe(true); }); - it("rejects empty queries array", () => { - expect(BatchInputSchema.safeParse({ queries: [] }).success).toBe(false); + it("rejects empty requests array", () => { + expect(BatchInputSchema.safeParse({ requests: [] }).success).toBe(false); }); - it("rejects more than 10 queries", () => { - const queries = Array.from({ length: 11 }, () => ({ - queryName: "overview", + it("rejects more than 20 requests", () => { + const requests = Array.from({ length: 21 }, (_, index) => ({ + id: `overview-${index}`, + method: "GET", + path: "/api/v1/sites/site-1/analytics/overview", })); - expect(BatchInputSchema.safeParse({ queries }).success).toBe(false); + expect(BatchInputSchema.safeParse({ requests }).success).toBe(false); + }); + + it("rejects non-GET subrequests and non-v1 paths", () => { + expect( + BatchInputSchema.safeParse({ + requests: [ + { + id: "bad", + method: "POST", + path: "/api/v1/sites/site-1/analytics/overview", + }, + ], + }).success, + ).toBe(false); + expect( + BatchInputSchema.safeParse({ + requests: [{ id: "bad", method: "GET", path: "/collect/event" }], + }).success, + ).toBe(false); }); }); @@ -496,17 +522,14 @@ describe("BatchResponseSchema", () => { requestId: "r", timestamp: "t", data: { - partialFailure: true, - results: [ - { queryName: "overview", ok: true, status: 200, data: {} }, - { - queryName: "trend", - ok: false, - status: 400, - error: { code: "bad_request", message: "Missing from" }, - }, + responses: [ + { id: "overview", status: 200, body: { data: {} } }, + { id: "trend", status: 400, body: { error: {} } }, ], }, + meta: { + partialFailure: true, + }, }).success, ).toBe(true); }); diff --git a/src/schemas/analytics.ts b/src/schemas/analytics.ts index a65c6fa3..e2c2582e 100644 --- a/src/schemas/analytics.ts +++ b/src/schemas/analytics.ts @@ -738,51 +738,46 @@ export const GeoPointsResponseSchema = createEnvelopeSchema( // Batch response export const BatchResultItemSchema = z.object({ - queryName: z.string(), - ok: z.boolean(), + id: z.string(), status: z.number().describe("HTTP status code of the sub-query response"), - data: z + body: z .unknown() - .optional() - .describe("Query result data (shape varies by queryName)"), - error: z - .object({ - code: z.string(), - message: z.string(), - }) - .optional() - .describe("Present only if this individual query failed"), + .nullable() + .describe("Subrequest response body, or null for empty responses"), }); export const BatchResponseSchema = createEnvelopeSchema( z.object({ + responses: z.array(BatchResultItemSchema), + }), +).extend({ + meta: z.object({ partialFailure: z .boolean() .describe("True if any sub-query returned a non-200 status"), - results: z.array(BatchResultItemSchema), }), -); +}); export const BatchInputSchema = z .object({ - from: z.number().int().optional(), - to: z.number().int().optional(), - interval: IntervalSchema.optional(), - timeZone: z.string().optional(), - queries: z + requests: z .array( z .object({ - queryName: z.string(), - from: z.number().int().optional(), - to: z.number().int().optional(), - interval: IntervalSchema.optional(), - limit: z.number().int().optional(), + id: z.string(), + method: z.literal("GET"), + path: z.string().startsWith("/api/v1/"), + query: z + .record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ) + .optional(), }) .strict(), ) .min(1) - .max(10), + .max(20), }) .strict(); diff --git a/workers/cf-worker.js b/workers/cf-worker.js index 4f721fa9..1c6e3264 100644 --- a/workers/cf-worker.js +++ b/workers/cf-worker.js @@ -3,173 +3,8 @@ import { runHourlyAggregation } from "../src/lib/edge/hourly-rollup"; import { IngestDurableObject as BaseIngestDurableObject } from "../src/lib/edge/ingest-do"; import { getScheduledTaskDefinition } from "../src/lib/edge/scheduled-task-registry"; import { runScheduledTask } from "../src/lib/edge/scheduled-task-runner"; - -// Session token 验证辅助函数 -function base64UrlDecode(input) { - const padded = - input.replace(/-/g, "+").replace(/_/g, "/") + - "===".slice((input.length + 3) % 4); - const binary = atob(padded); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - out[i] = binary.charCodeAt(i); - } - return out; -} - -function bytesEqual(a, b) { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a[i] ^ b[i]; - } - return diff === 0; -} - -async function hmacSha256(message, secret) { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(message), - ); - return new Uint8Array(sig); -} - -async function verifySessionToken(token, secret) { - if (!token || token.length < 20) return null; - const [payloadPart, signaturePart] = token.split("."); - if (!payloadPart || !signaturePart) return null; - - const expectedSig = await hmacSha256(payloadPart, secret); - let actualSig; - try { - actualSig = base64UrlDecode(signaturePart); - } catch { - return null; - } - if (!bytesEqual(expectedSig, actualSig)) return null; - - try { - const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadPart)); - const parsed = JSON.parse(payloadJson); - if (!parsed || typeof parsed !== "object") return null; - - const { userId, username, exp } = parsed; - if (!userId || !username || !exp) return null; - if (Math.floor(Date.now() / 1000) >= exp) return null; - - return parsed; - } catch { - return null; - } -} - -async function deriveSessionSecret(env) { - const explicit = env.DASHBOARD_SESSION_SECRET || env.SESSION_SECRET; - if (explicit) return explicit; - - const root = env.MAIN_SECRET || env.DAILY_SALT_SECRET; - if (!root) return null; - - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(root), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode("insightflare:dashboard-session:v1"), - ); - return Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -function extractSessionToken(request) { - // 从 Authorization header 提取 - const auth = request.headers.get("authorization") || ""; - if (auth.toLowerCase().startsWith("bearer ")) { - return auth.slice(7).trim(); - } - - // 从 cookie 提取 - const cookie = request.headers.get("cookie") || ""; - if (!cookie) return ""; - const parts = cookie.split(";"); - for (const part of parts) { - const [rawKey, ...rawValue] = part.trim().split("="); - if (rawKey === "if_session") { - try { - return decodeURIComponent(rawValue.join("=")); - } catch { - return rawValue.join("="); - } - } - } - return ""; -} - -async function canSessionReadSite(env, session, siteId) { - if (session.systemRole === "admin") { - const site = await env.DB.prepare("SELECT id FROM sites WHERE id=? LIMIT 1") - .bind(siteId) - .first(); - return Boolean(site?.id); - } - - const site = await env.DB.prepare( - `SELECT s.id - FROM sites s - INNER JOIN teams t ON t.id = s.team_id - LEFT JOIN team_members tm ON tm.team_id = s.team_id AND tm.user_id = ? - WHERE s.id = ? AND (t.owner_user_id = ? OR tm.user_id IS NOT NULL) - LIMIT 1`, - ) - .bind(session.userId, siteId, session.userId) - .first(); - - return Boolean(site?.id); -} - -async function handleAdminWs(request, env) { - // 验证 Session token - const secret = await deriveSessionSecret(env); - if (!secret) { - return new Response("Service unavailable", { status: 503 }); - } - - const token = extractSessionToken(request); - const session = await verifySessionToken(token, secret); - if (!session) { - return new Response("Unauthorized", { status: 401 }); - } - - const incomingUrl = new URL(request.url); - const siteId = incomingUrl.searchParams.get("siteId"); - if (!siteId) { - return new Response("siteId is required", { status: 400 }); - } - - const allowed = await canSessionReadSite(env, session, siteId); - if (!allowed) { - return new Response("Forbidden", { status: 403 }); - } - - const doId = env.INGEST_DO.idFromName(siteId); - const stub = env.INGEST_DO.get(doId); - const forwardUrl = "https://ingest.internal/ws" + incomingUrl.search; - return stub.fetch(new Request(forwardUrl, request)); -} +import apiApp from "../src/lib/hono/app"; +import { shouldUseHono } from "../src/lib/hono/path-match"; export class IngestDurableObject extends BaseIngestDurableObject {} @@ -180,9 +15,8 @@ function shouldSkipScheduledTasks(env) { export default { async fetch(request, env, ctx) { const url = new URL(request.url); - const pathname = url.pathname; - if (pathname === "/admin/ws") { - return handleAdminWs(request, env); + if (shouldUseHono(url.pathname)) { + return apiApp.fetch(request, env, ctx); } return nextWorker.fetch(request, env, ctx); },