From 5e375420a859e7a41b2116981e10e8ef5506a2f2 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:27:04 +0800 Subject: [PATCH 01/40] fix: add AutoTransition to loading/idle state transitions on management pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap buttons and content areas with AutoTransition for smooth fade animations between loading and idle states on API Keys, Scheduled Tasks, and System Performance pages — matching the existing pattern in team/user settings. Co-Authored-By: Claude Opus 4.7 --- src/components/dashboard/api-keys-client.tsx | 335 +++++++++++------- .../dashboard/scheduled-tasks-client.tsx | 57 +-- .../dashboard/system-performance-client.tsx | 179 +++++++--- 3 files changed, 370 insertions(+), 201 deletions(-) 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/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/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) + : "--"} +
+
From f099c6ab2a4b91f2a5e04af9b714c78358bd036a Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:29:22 +0800 Subject: [PATCH 02/40] fix: generalize cross-breakdown dimensions and align OpenAPI spec - Extend cross-breakdown endpoint to support 27 analytics dimensions (page, referrer, UTM, client, geo) instead of only 6 client dimensions - Add resolveCrossBreakdownDimension() for generic dimension-to-SQL mapping - Remove timeRange/filters from FunnelAnalysisRequest OpenAPI schema to match actual Zod validation (timeRange via query params only) - Add missing fieldValueType parameter to event-fields/values OpenAPI spec --- docs/openapi.json | 38 +++---- docs/openapi.yaml | 37 ++++--- scripts/generate-openapi.ts | 22 ++-- src/lib/edge/__tests__/api-v1.test.ts | 8 ++ .../query-technology-coverage.test.ts | 33 +++--- .../query-technology-handlers.test.ts | 60 +++++----- src/lib/edge/__tests__/query.test.ts | 4 +- src/lib/edge/api-v1-helpers.ts | 22 ++++ src/lib/edge/api-v1.ts | 7 +- src/lib/edge/query/core-dimensions.ts | 103 ++++++++++++++++++ src/lib/edge/query/router.ts | 4 +- src/lib/edge/query/technology/client-cross.ts | 23 ++-- src/lib/edge/query/technology/handlers.ts | 23 ++-- 13 files changed, 264 insertions(+), 120 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index fb039558..fe5a8059 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2334,7 +2334,7 @@ "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": [ { @@ -2390,7 +2390,7 @@ "type": "string", "maxLength": 120 }, - "description": "Primary dimension." + "description": "Primary dimension (e.g. client.browser, geo.country, page.path)." }, { "name": "secondary", @@ -2399,7 +2399,7 @@ "type": "string", "maxLength": 120 }, - "description": "Secondary dimension." + "description": "Secondary dimension (must differ from primary)." }, { "name": "metric", @@ -3693,6 +3693,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", @@ -4810,11 +4819,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 +4830,6 @@ "value": "signup", "label": "Signup" } - ], - "filters": [ - { - "field": "geo.country", - "op": "in", - "value": ["US", "CA"] - } ] } } @@ -8080,12 +8077,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 +8087,6 @@ "items": { "$ref": "#/components/schemas/FunnelStepInput" } - }, - "filters": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ComplexFilter" - } } }, "additionalProperties": false diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c2da3cf7..5d8e356e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1637,7 +1637,9 @@ paths: 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: @@ -1686,13 +1688,13 @@ paths: 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: @@ -2613,6 +2615,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: @@ -3351,14 +3365,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 @@ -5568,22 +5575,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 diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 05a0502b..531e58a1 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1528,17 +1528,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, }, @@ -2243,7 +2242,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 +2251,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 +2462,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 }, @@ -3266,9 +3274,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"] }], }, }, }, diff --git a/src/lib/edge/__tests__/api-v1.test.ts b/src/lib/edge/__tests__/api-v1.test.ts index 91e89eb5..a173fff3 100644 --- a/src/lib/edge/__tests__/api-v1.test.ts +++ b/src/lib/edge/__tests__/api-v1.test.ts @@ -1359,6 +1359,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 () => { 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..0d0ea2bc 100644 --- a/src/lib/edge/__tests__/query.test.ts +++ b/src/lib/edge/__tests__/query.test.ts @@ -1429,14 +1429,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, ); 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..4cb25ccc 100644 --- a/src/lib/edge/api-v1.ts +++ b/src/lib/edge/api-v1.ts @@ -47,6 +47,7 @@ import { parseTimeRange, requireScope, TIME_PRESETS, + validateCrossBreakdownDimension, validateDimension, } from "./api-v1-helpers"; import { @@ -1364,8 +1365,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; 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/router.ts b/src/lib/edge/query/router.ts index bc68e325..eeba29ec 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -46,8 +46,8 @@ import { handleBrowserRadar, handleBrowserTrend, handleBrowserVersionBreakdown, - handleClientCrossBreakdown, handleClientDimensionTrend, + handleCrossBreakdown, handleReferrerDimensionTrend, handleReferrerRadar, handleUtmDimensionTrend, @@ -159,7 +159,7 @@ export async function routeQuery( return handleUtmDimensionTrend(env, siteId, url, ctx); } if (pathname === "client-cross-breakdown") { - return handleClientCrossBreakdown(env, siteId, url, ctx); + return handleCrossBreakdown(env, siteId, url, ctx); } if (pathname === "utm-source") { return handleDimension( 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, From 2a0fa313df6c852642212c5f00f193f043cc7e6e Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:33:55 +0800 Subject: [PATCH 03/40] fix: add missing timeParams to funnels/analysis OpenAPI spec The POST /funnels/analysis endpoint accepts from/to/preset/timeZone query params (used in the handler via parseTimeRange), but the OpenAPI spec did not declare them. This would cause SDK generators to omit time range arguments for ad-hoc funnel analysis. --- docs/openapi.json | 36 ++++++++++++++++++++++++++++++++++++ docs/openapi.yaml | 31 +++++++++++++++++++++++++++++++ scripts/generate-openapi.ts | 2 +- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/openapi.json b/docs/openapi.json index fe5a8059..af14a767 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -4801,6 +4801,42 @@ "parameters": [ { "$ref": "#/components/parameters/siteId" + }, + { + "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." } ], "post": { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5d8e356e..a4fb1647 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3349,6 +3349,37 @@ paths: /api/v1/sites/{siteId}/funnels/analysis: parameters: - *a3 + - 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. post: operationId: analyzeFunnel summary: Analyze funnel diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 531e58a1..0759cc10 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -2632,7 +2632,7 @@ function buildPaths(): OpenAPISpec["paths"] { }), }, "/api/v1/sites/{siteId}/funnels/analysis": { - parameters: [siteParam], + parameters: [siteParam, ...timeParams()], post: op({ operationId: "analyzeFunnel", summary: "Analyze funnel", From 9551b78d25225fb16cb4577d08e6e5ec6c24a18b Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:46:57 +0800 Subject: [PATCH 04/40] fix: align OpenAPI contract metadata Document ISO 8601 timestamp semantics and upstream 429 behavior at the top level. Add required-scope extensions, reusable parameters, opaque visitor/session IDs, and constrained complex filters. Regenerate OpenAPI artifacts and strengthen contract checks against future drift. --- docs/openapi.json | 1886 ++++++++------------------- docs/openapi.yaml | 1926 ++++++++-------------------- scripts/check-openapi-contract.mjs | 91 +- scripts/generate-openapi.ts | 343 +++-- scripts/skills-template.json | 23 +- 5 files changed, 1444 insertions(+), 2825 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index af14a767..b2855f09 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "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.", + "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": "1.0.0", "contact": { "name": "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": { @@ -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": { @@ -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", @@ -1211,13 +1025,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 +1094,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:read"] }, "patch": { "operationId": "updateSite", @@ -1368,7 +1184,8 @@ "409": { "$ref": "#/components/responses/Conflict" } - } + }, + "x-required-scopes": ["site:write"] }, "delete": { "operationId": "deleteSite", @@ -1388,13 +1205,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 +1263,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updateTrackingSettings", @@ -1521,13 +1340,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 +1391,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 +1444,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updatePrivacySettings", @@ -1690,13 +1512,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 +1562,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site_config:read"] }, "patch": { "operationId": "updateSharingSettings", @@ -1806,13 +1630,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 +1660,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 +1736,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 +1753,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 +1820,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 +1837,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 +1908,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 +1928,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,13 +2010,14 @@ "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": { @@ -2338,50 +2027,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": "primary", @@ -2466,13 +2124,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 +2141,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 +2226,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 +2335,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 +2352,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 +2425,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 +2442,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 +2519,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 +2539,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 +2614,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 +2631,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 +2722,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 +2739,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 +2795,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 +2812,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 +2889,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 +2994,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 +3054,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 +3071,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", @@ -3765,13 +3172,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": { @@ -3781,70 +3189,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", @@ -3919,16 +3282,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": { @@ -3980,16 +3344,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": { @@ -3999,60 +3364,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": { @@ -4108,16 +3435,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": { @@ -4127,60 +3455,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": { @@ -4235,13 +3525,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": { @@ -4251,70 +3542,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", @@ -4388,16 +3634,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": { @@ -4448,16 +3695,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": { @@ -4466,61 +3714,23 @@ "description": "Lists events for a session.", "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": { @@ -4575,13 +3785,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": { @@ -4591,40 +3802,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": { @@ -4687,7 +3874,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] }, "post": { "operationId": "createFunnel", @@ -4794,49 +3982,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" }, { - "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" } ], "post": { @@ -4946,16 +4111,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": { @@ -5018,7 +4184,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] }, "patch": { "operationId": "updateFunnel", @@ -5113,7 +4280,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["site:write"] }, "delete": { "operationId": "deleteFunnel", @@ -5133,16 +4301,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": { @@ -5152,40 +4321,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": { @@ -5287,13 +4432,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": { @@ -5303,50 +4449,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": { @@ -5390,13 +4505,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": { @@ -5406,60 +4522,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": { @@ -5513,16 +4591,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": { @@ -5532,50 +4611,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", @@ -5629,13 +4677,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": { @@ -5677,13 +4726,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": { @@ -5750,13 +4800,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": { @@ -5824,13 +4875,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": { @@ -5899,7 +4951,8 @@ "404": { "$ref": "#/components/responses/NotFound" } - } + }, + "x-required-scopes": ["analytics:read"] } }, "/api/v1/batch": { @@ -6018,7 +5071,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" } - } + }, + "x-required-scopes": ["analytics:read"] } } }, @@ -6269,7 +5323,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": { @@ -6296,45 +5350,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" } } }, @@ -6929,7 +6040,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", @@ -7705,7 +6817,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 @@ -8733,7 +7872,7 @@ } }, "parameters": { - "siteId": { + "SiteIdPathParam": { "name": "siteId", "in": "path", "required": true, @@ -8743,7 +7882,7 @@ }, "description": "Site UUID." }, - "dimension": { + "DimensionPathParam": { "name": "dimension", "in": "path", "required": true, @@ -8753,7 +7892,7 @@ }, "description": "Stable analytics dimension key." }, - "eventName": { + "EventNamePathParam": { "name": "eventName", "in": "path", "required": true, @@ -8763,7 +7902,7 @@ }, "description": "Event name." }, - "eventId": { + "EventIdPathParam": { "name": "eventId", "in": "path", "required": true, @@ -8773,27 +7912,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, @@ -8802,6 +7941,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 a4fb1647..089f76fc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,12 +1,23 @@ 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. + 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: 1.0.0 contact: name: InsightFlare @@ -14,6 +25,11 @@ info: 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 @@ -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 @@ -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 @@ -869,10 +692,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 +725,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:read patch: operationId: updateSite summary: Update site @@ -945,6 +772,8 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" + x-required-scopes: + - site:write delete: operationId: deleteSite summary: Delete site @@ -960,6 +789,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 +833,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 +894,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 +930,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 +967,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 +1015,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 +1049,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 +1096,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 +1120,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 +1184,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 +1196,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 +1226,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 +1238,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 +1276,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 +1289,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,6 +1335,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/cross-breakdowns: parameters: - *a3 @@ -1643,46 +1349,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: primary in: query schema: @@ -1734,6 +1405,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/compare: parameters: - *a3 @@ -1744,46 +1417,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: @@ -1827,6 +1465,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/analytics/explore: parameters: - *a3 @@ -1899,6 +1539,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 @@ -1909,58 +1551,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 @@ -1995,6 +1591,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/event-types: parameters: - *a3 @@ -2005,39 +1603,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: @@ -2079,10 +1648,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 @@ -2090,51 +1661,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 @@ -2182,6 +1713,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events: parameters: - *a3 @@ -2192,60 +1725,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: @@ -2294,6 +1780,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/summary: parameters: - *a3 @@ -2304,46 +1792,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 @@ -2372,6 +1825,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/timeseries: parameters: - *a3 @@ -2382,58 +1837,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: @@ -2471,6 +1880,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/events/search: parameters: - *a3 @@ -2529,10 +1940,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 @@ -2560,6 +1973,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 @@ -2570,39 +1985,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: @@ -2659,76 +2045,31 @@ paths: timeRange: *a1 "400": $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/sites/{siteId}/visitors: - parameters: - - *a3 - get: - operationId: listVisitors - summary: List visitors - description: Lists visitors with cursor pagination. - 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. + "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: + parameters: + - *a3 + get: + operationId: listVisitors + summary: List visitors + description: Lists visitors with cursor pagination. + tags: + - Visitors + parameters: + - $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: @@ -2779,11 +2120,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 @@ -2811,6 +2154,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/visitors/{visitorId}/sessions: parameters: - *a3 @@ -2822,53 +2167,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 @@ -2906,6 +2210,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 @@ -2917,53 +2223,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 @@ -2992,6 +2257,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/sessions: parameters: - *a3 @@ -3002,60 +2269,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: @@ -3096,11 +2316,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 @@ -3128,6 +2350,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 @@ -3139,53 +2363,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 @@ -3214,6 +2397,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/funnels: parameters: - *a3 @@ -3224,39 +2409,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 @@ -3297,6 +2453,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read post: operationId: createFunnel summary: Create funnel @@ -3346,40 +2504,15 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write /api/v1/sites/{siteId}/funnels/analysis: parameters: - *a3 - - 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" post: operationId: analyzeFunnel summary: Analyze funnel @@ -3408,7 +2541,7 @@ paths: default: summary: analyzeFunnel value: - data: &a13 + data: &a14 steps: - index: 0 label: Pricing @@ -3447,10 +2580,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 @@ -3478,6 +2614,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read patch: operationId: updateFunnel summary: Update funnel @@ -3519,6 +2657,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - site:write delete: operationId: deleteFunnel summary: Delete funnel @@ -3534,10 +2674,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 @@ -3545,39 +2687,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 @@ -3591,7 +2704,7 @@ paths: value: data: funnel: *a12 - analysis: *a13 + analysis: *a14 meta: requestId: req_abc123 generatedAt: 2026-06-26T12:00:00Z @@ -3604,6 +2717,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/performance/summary: parameters: - *a3 @@ -3614,46 +2729,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 @@ -3682,6 +2762,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/performance/timeseries: parameters: - *a3 @@ -3692,58 +2774,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 @@ -3776,6 +2812,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 @@ -3787,46 +2825,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: @@ -3865,6 +2868,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 @@ -3896,6 +2901,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/events: parameters: - *a3 @@ -3937,6 +2944,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/sessions: parameters: - *a3 @@ -3978,6 +2987,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/sites/{siteId}/realtime/snapshot: parameters: - *a3 @@ -4013,6 +3024,8 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + x-required-scopes: + - analytics:read /api/v1/batch: post: operationId: batch @@ -4083,6 +3096,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + x-required-scopes: + - analytics:read components: schemas: Meta: @@ -4301,10 +3316,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 @@ -4332,44 +3348,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. @@ -4387,7 +3442,7 @@ components: restricted means only listed siteIds. siteIds: type: array - items: &a14 + items: &a15 type: string format: uuid Token: @@ -4401,7 +3456,7 @@ components: - scopes - siteAccess properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 @@ -4412,7 +3467,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: @@ -4431,7 +3486,7 @@ components: - id - name properties: - id: *a14 + id: *a15 name: type: string maxLength: 120 @@ -4473,7 +3528,7 @@ components: scope: type: string maxLength: 80 - siteId: *a14 + siteId: *a15 TokenCheckResponse: description: Response envelope. allOf: @@ -4491,7 +3546,7 @@ components: properties: scope: type: string - siteId: *a14 + siteId: *a15 allowed: type: boolean reason: @@ -4618,11 +3673,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: @@ -4646,15 +3701,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: @@ -4767,7 +3822,7 @@ components: data: type: object properties: - siteId: *a14 + siteId: *a15 src: type: string format: uri @@ -4787,6 +3842,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 @@ -4864,7 +3927,7 @@ components: - string - "null" format: date-time - latestAvailableAt: *a15 + latestAvailableAt: *a16 links: $ref: "#/components/schemas/LinkMap" OverviewMetrics: @@ -4903,8 +3966,8 @@ components: type: object description: One time bucket of analytics metrics. properties: - start: *a15 - end: *a15 + start: *a16 + end: *a16 views: type: integer sessions: @@ -5123,7 +4186,7 @@ components: items: type: object properties: - start: *a15 + start: *a16 size: type: integer minimum: 0 @@ -5182,11 +4245,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: @@ -5328,6 +4391,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 @@ -5364,8 +4437,8 @@ components: visitorId: type: string maxLength: 160 - firstSeenAt: *a15 - lastSeenAt: *a15 + firstSeenAt: *a16 + lastSeenAt: *a16 views: type: integer minimum: 0 @@ -5408,7 +4481,7 @@ components: visitorId: type: string maxLength: 160 - startedAt: *a15 + startedAt: *a16 endedAt: type: - string @@ -5476,8 +4549,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. @@ -5648,8 +4721,8 @@ components: - createdAt - updatedAt properties: - id: *a14 - siteId: *a14 + id: *a15 + siteId: *a15 name: type: string maxLength: 200 @@ -5662,8 +4735,8 @@ components: type: array items: $ref: "#/components/schemas/FunnelStep" - createdAt: *a15 - updatedAt: *a15 + createdAt: *a16 + updatedAt: *a16 links: $ref: "#/components/schemas/LinkMap" FunnelResponse: @@ -5886,7 +4959,7 @@ components: type: string enum: - healthy - timestamp: *a15 + timestamp: *a16 CollectPage: type: object description: Page context for a collect payload. @@ -6057,7 +5130,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 @@ -6065,7 +5138,7 @@ components: type: string format: uuid description: Site UUID. - dimension: + DimensionPathParam: name: dimension in: path required: true @@ -6073,7 +5146,7 @@ components: type: string maxLength: 120 description: Stable analytics dimension key. - eventName: + EventNamePathParam: name: eventName in: path required: true @@ -6081,7 +5154,7 @@ components: type: string maxLength: 120 description: Event name. - eventId: + EventIdPathParam: name: eventId in: path required: true @@ -6089,23 +5162,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 @@ -6113,28 +5186,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/scripts/check-openapi-contract.mjs b/scripts/check-openapi-contract.mjs index 981ad431..be7f0d0b 100644 --- a/scripts/check-openapi-contract.mjs +++ b/scripts/check-openapi-contract.mjs @@ -61,6 +61,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 +167,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 +223,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 +285,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"); diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 0759cc10..67a6cece 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -16,11 +16,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 +45,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 +179,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 +215,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 +589,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 +617,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: { @@ -942,7 +934,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 +1343,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, @@ -1849,12 +1861,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": { @@ -2646,7 +2659,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", @@ -2680,7 +2693,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", @@ -3003,21 +3016,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"], @@ -3384,7 +3425,7 @@ 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.", + "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: "1.0.0", contact: { name: "InsightFlare", @@ -3395,6 +3436,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" }, ], @@ -3431,55 +3477,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 }, 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." ] } From c98d585e0c0605b666bf291a482eae0c6361b22c Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:59:15 +0800 Subject: [PATCH 05/40] chore: add explicit prettier defaults and read API version from package.json - Add explicit prettier configuration rules for better documentation - Update generate-openapi.ts and generate-skills.ts to read version from package.json - Regenerate OpenAPI and Skills specs with correct version (0.1.0) --- docs/openapi.json | 6 +++--- docs/openapi.yaml | 6 +++--- docs/skills.json | 2 +- prettier.config.js | 7 +++++++ scripts/generate-openapi.ts | 15 +++++++++++---- scripts/generate-skills.ts | 9 +++++++-- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index b2855f09..5eb1f855 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "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 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": "1.0.0", + "version": "0.1.0", "contact": { "name": "InsightFlare", "url": "https://github.com/ravelloh/InsightFlare" @@ -232,7 +232,7 @@ "summary": "getApiRoot", "value": { "data": { - "version": "1.0.0", + "version": "0.1.0", "service": "InsightFlare Analytics API", "links": { "self": "/api/v1", @@ -408,7 +408,7 @@ "summary": "getCapabilities", "value": { "data": { - "apiVersion": "1.0.0", + "apiVersion": "0.1.0", "features": { "sites": true, "tracking": true, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 089f76fc..33ba8f72 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -18,7 +18,7 @@ info: 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: 1.0.0 + version: 0.1.0 contact: name: InsightFlare url: https://github.com/ravelloh/InsightFlare @@ -179,7 +179,7 @@ paths: summary: getApiRoot value: data: - version: 1.0.0 + version: 0.1.0 service: InsightFlare Analytics API links: self: /api/v1 @@ -304,7 +304,7 @@ paths: summary: getCapabilities value: data: - apiVersion: 1.0.0 + apiVersion: 0.1.0 features: sites: true tracking: true diff --git a/docs/skills.json b/docs/skills.json index b6df01ab..0624d89e 100644 --- a/docs/skills.json +++ b/docs/skills.json @@ -1,6 +1,6 @@ { "api": "InsightFlare Analytics API", - "version": "1.0.0", + "version": "0.1.0", "description": "Privacy-focused web analytics platform.", "baseUrl": "https://insight.ravelloh.com", "openapiUrl": "/.well-known/openapi.json", 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/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 67a6cece..ad41f4df 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1,10 +1,17 @@ #!/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"; +const ROOT = resolve(import.meta.dirname, ".."); + +function getAppVersion(): string { + const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")); + return pkg.version; +} + type HttpMethod = "get" | "post" | "patch" | "delete"; interface Operation { @@ -2914,7 +2921,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", @@ -2942,7 +2949,7 @@ function responseExampleFor(schemaName: string | null, operationId: string) { ], }), CapabilitiesResponse: success({ - apiVersion: "1.0.0", + apiVersion: getAppVersion(), features: { sites: true, tracking: true, @@ -3426,7 +3433,7 @@ function buildSpec(): OpenAPISpec { 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 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: "1.0.0", + version: getAppVersion(), contact: { name: "InsightFlare", url: "https://github.com/ravelloh/InsightFlare", diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts index 39115065..618cad0d 100644 --- a/scripts/generate-skills.ts +++ b/scripts/generate-skills.ts @@ -1,16 +1,21 @@ #!/usr/bin/env tsx import { execSync } from "child_process"; -import { writeFileSync } from "fs"; +import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; const ROOT = resolve(import.meta.dirname, ".."); const OUTPUT_PATH = resolve(ROOT, "docs/skills.json"); +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", openapiUrl: "/.well-known/openapi.json", From a48bf7f68396982f7a5ed316aad4f22c23f31cee Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:05:44 +0800 Subject: [PATCH 06/40] test: fix the test timeout issue --- src/lib/dashboard/__tests__/client-request.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/dashboard/__tests__/client-request.test.ts b/src/lib/dashboard/__tests__/client-request.test.ts index 9f0c3367..e089de45 100644 --- a/src/lib/dashboard/__tests__/client-request.test.ts +++ b/src/lib/dashboard/__tests__/client-request.test.ts @@ -4,6 +4,11 @@ import { fetchPrivateJson, fetchPrivateJsonMutate, } 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 +22,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 { @@ -178,12 +184,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(); }); }); From 9f30af04714e6c63995b8b4f3744f9f7b5785715 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:19:21 +0800 Subject: [PATCH 07/40] chore: add changelog for v0.2.0 release with API enhancements and developer experience improvements --- changelog/v0.2.0.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 changelog/v0.2.0.md 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. From 1aa8f6c1ac37d33ad64322982c2a6e008e7a5c8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:19:44 +0000 Subject: [PATCH 08/40] chore(release): sync package version to v0.2.0 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fe8929d..7b30152d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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", diff --git a/package.json b/package.json index dbc51061..2775b5c8 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", From 4297941293aa74139de094786f4e56cba9ee1a3d Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:36:36 +0800 Subject: [PATCH 09/40] docs: add AI Agents integration section to README --- .github/readme/README.en.md | 17 ++++++++++++++++- .github/readme/README.zh.md | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) 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 | From ef4fb94677f67e9b691f9362fde8085ca2c28327 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:14:12 +0800 Subject: [PATCH 10/40] fix: sync release api discovery artifacts --- .github/workflows/release.yml | 23 +++++++++-- docs/openapi.json | 6 +-- docs/openapi.yaml | 6 +-- docs/skills.json | 4 +- eslint.config.js | 2 +- scripts/generate-skills.ts | 2 +- src/app/.well-known/openapi.json/route.ts | 4 +- src/app/.well-known/skills.json/route.ts | 4 +- .../well-known-discovery-routes.test.ts | 39 +++++++++++++++++++ 9 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/app/__tests__/well-known-discovery-routes.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f18ac4e7..f439ef11 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 + node scripts/check-openapi-contract.mjs + + 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 diff --git a/docs/openapi.json b/docs/openapi.json index 5eb1f855..51bc82d7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "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 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.1.0", + "version": "0.2.0", "contact": { "name": "InsightFlare", "url": "https://github.com/ravelloh/InsightFlare" @@ -232,7 +232,7 @@ "summary": "getApiRoot", "value": { "data": { - "version": "0.1.0", + "version": "0.2.0", "service": "InsightFlare Analytics API", "links": { "self": "/api/v1", @@ -408,7 +408,7 @@ "summary": "getCapabilities", "value": { "data": { - "apiVersion": "0.1.0", + "apiVersion": "0.2.0", "features": { "sites": true, "tracking": true, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 33ba8f72..a9c34664 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -18,7 +18,7 @@ info: 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.1.0 + version: 0.2.0 contact: name: InsightFlare url: https://github.com/ravelloh/InsightFlare @@ -179,7 +179,7 @@ paths: summary: getApiRoot value: data: - version: 0.1.0 + version: 0.2.0 service: InsightFlare Analytics API links: self: /api/v1 @@ -304,7 +304,7 @@ paths: summary: getCapabilities value: data: - apiVersion: 0.1.0 + apiVersion: 0.2.0 features: sites: true tracking: true diff --git a/docs/skills.json b/docs/skills.json index 0624d89e..c5d8e408 100644 --- a/docs/skills.json +++ b/docs/skills.json @@ -1,8 +1,8 @@ { "api": "InsightFlare Analytics API", - "version": "0.1.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..9c0e1584 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,7 +110,7 @@ export default [ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["*.ts", "src/app/.well-known/*/route.ts"], + allowDefaultProject: ["*.ts"], }, tsconfigRootDir: import.meta.dirname, }, diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts index 618cad0d..4dd233b4 100644 --- a/scripts/generate-skills.ts +++ b/scripts/generate-skills.ts @@ -17,7 +17,7 @@ function generate() { api: "InsightFlare Analytics API", version: getAppVersion(), 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/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/__tests__/well-known-discovery-routes.test.ts b/src/app/__tests__/well-known-discovery-routes.test.ts new file mode 100644 index 00000000..e0ec176d --- /dev/null +++ b/src/app/__tests__/well-known-discovery-routes.test.ts @@ -0,0 +1,39 @@ +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(); + + expect(body.servers).toEqual([ + expect.objectContaining({ url: "https://analytics.example.test" }), + ]); + }); +}); From d38e6cd28cdab33b88dccc44a9853f30f852ab7a Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:20:32 +0800 Subject: [PATCH 11/40] Implement API v1 analytics endpoints --- .../well-known-discovery-routes.test.ts | 4 +- src/lib/edge/__tests__/api-v1.test.ts | 81 +- src/lib/edge/api-v1.ts | 701 +++++++++++++++++- 3 files changed, 763 insertions(+), 23 deletions(-) diff --git a/src/app/__tests__/well-known-discovery-routes.test.ts b/src/app/__tests__/well-known-discovery-routes.test.ts index e0ec176d..4164c21f 100644 --- a/src/app/__tests__/well-known-discovery-routes.test.ts +++ b/src/app/__tests__/well-known-discovery-routes.test.ts @@ -30,7 +30,9 @@ describe(".well-known discovery routes", () => { }, }), ); - const body = await response.json(); + 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/lib/edge/__tests__/api-v1.test.ts b/src/lib/edge/__tests__/api-v1.test.ts index a173fff3..f8f8487c 100644 --- a/src/lib/edge/__tests__/api-v1.test.ts +++ b/src/lib/edge/__tests__/api-v1.test.ts @@ -1382,7 +1382,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" }, @@ -1399,6 +1405,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 () => { @@ -1831,16 +1840,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 () => { @@ -1937,12 +1993,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/api-v1.ts b/src/lib/edge/api-v1.ts index 4cb25ccc..d0925c21 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, @@ -550,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) => { @@ -815,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, @@ -932,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", @@ -1404,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") { @@ -1852,16 +2476,65 @@ async function handlePerformance( 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( @@ -2055,7 +2728,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); From 5dbf87f76488bb7dcdd420a79ba43bb70dfe3a20 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:39:50 +0800 Subject: [PATCH 12/40] fix: relax CSP connect-src and expand eslint allowDefaultProject - Simplify CSP connect-src from domain whitelist to `https: wss:` to unblock caniuse, geo translation, and iconify flag resources - Add .well-known route files to eslint allowDefaultProject --- eslint.config.js | 7 ++++++- next.config.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 9c0e1584..bcebf5b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,7 +110,12 @@ export default [ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["*.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'", From 30b7830514abb090766ab8923942c0293f68c6bf Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:43:54 +0800 Subject: [PATCH 13/40] fix: preserve current analytics tab when clicking site in sidebar --- src/components/dashboard/dashboard-shell.tsx | 22 +++++++++++++ .../dashboard/sidebar-site-details.tsx | 32 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 6d1a67ab..cc793fca 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -326,6 +326,27 @@ export function DashboardShell({ const mainLayoutSegments = visibleLayoutSegments(useSelectedLayoutSegments()); const mainSiteSection = mainLayoutSegments[1] || ""; const mainSiteSubSection = mainLayoutSegments[2] || ""; + const validAnalyticsSections = [ + "realtime", + "pages", + "referrers", + "sessions", + "events", + "visitors", + "geo", + "devices", + "browsers", + "performance", + "settings", + "campaigns", + "funnels", + "retention", + ] as const; + const currentAnalyticsSection = validAnalyticsSections.includes( + mainSiteSection as (typeof validAnalyticsSections)[number], + ) + ? (mainSiteSection as (typeof validAnalyticsSections)[number]) + : undefined; const routeState = parseSidebarRouteState(livePathname, activeTeamSlug); const hasManagementSections = Boolean( managementSections && managementSections.length > 0, @@ -575,6 +596,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/sidebar-site-details.tsx b/src/components/dashboard/sidebar-site-details.tsx index 10cb442e..463f9222 100644 --- a/src/components/dashboard/sidebar-site-details.tsx +++ b/src/components/dashboard/sidebar-site-details.tsx @@ -60,11 +60,28 @@ interface SidebarSiteSummary { iconPath?: string; } +type AnalyticsSection = + | "realtime" + | "pages" + | "referrers" + | "sessions" + | "events" + | "visitors" + | "geo" + | "devices" + | "browsers" + | "performance" + | "settings" + | "campaigns" + | "funnels" + | "retention"; + interface SidebarSiteDetailsProps { locale: Locale; teamId: string; teamSlug: string; activeSiteSlug?: string; + currentSection?: AnalyticsSection; sites: SidebarSiteSummary[]; labels: { views: string; @@ -88,8 +105,11 @@ function buildSitePath( locale: Locale, teamSlug: string, siteSlug: string, + section?: AnalyticsSection, ): 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 +221,7 @@ export function SidebarSiteDetails({ teamId, teamSlug, activeSiteSlug, + currentSection, sites, labels, messages, @@ -408,7 +429,14 @@ export function SidebarSiteDetails({ tooltip={site.name} className="h-8 rounded-none" > - + Date: Sat, 27 Jun 2026 22:49:00 +0800 Subject: [PATCH 14/40] chore: new icon with more beautiful views --- public/android-chrome-192x192.png | Bin 34091 -> 21459 bytes public/android-chrome-512x512.png | Bin 210262 -> 100111 bytes public/favicon-16x16.png | Bin 470 -> 667 bytes public/favicon-32x32.png | Bin 1192 -> 1759 bytes public/favicon.ico | Bin 15406 -> 56045 bytes public/icon-1024.png | Bin 0 -> 101618 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/icon-1024.png diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 832015be8c53100a18b39e472aa0945f3f9a4584..a9bafb61abc978b28d671449946b9dd1df662d9c 100644 GIT binary patch literal 21459 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 z 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 literal 210262 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 delta 445 zcmV;u0Yd(p1=a(QBYy#lNkl2RC^8b zK90PKqJ>KGGV%h-r$_ndt;e?BNl&j5Y^+A>Y4{tUc&L+)?uaXX!?}M#m;uD(Aj)ONiV=HsfQQr^9STeJIXX*tOHeEgi2VbwAa7ePA=g24S8~n zTswl>7;5AAd)_*Mi)cpU$s9no8hwSueOK@ZO9ycI1UAcua1+m#e-8NpKRbR|e*pjh n|NjvxMZ5q200v1!K~w_(3U!^~m1~2)j3EvHnBYyx1a7bBm000XU000XU0RWnu7ytkUkx4{BRCt`lS9?$s=NA8x z{dPB5lJHQGBDU6gubqxDIT1BYcT$QMU4Pt7OZM`Z9RGB&=7q&q4uE#VA;nzY3vr{)Eiz!{{@pP?QuVyZGY`L4 z7l^x31=?Z9HQl?Fp!HizWenUc0e}<;M2eg~dbzG@;RiK@d|Df_PiZGzpbglJGH$$< zzDwl1ytgZ_91#*fs0Gc4b6OX&hc!b2Xo!#j{%IL2>VJvIJJ=NfKa)Q?S-Wr1hqa&? zbxOLB9W+CNq?srOO;G5Y#m?_EuR@+o_71Kb6g)!86&DX2yze8jat9#~YXU-8bKV9~ zLE);pBk#`{e=ntJpf!#kTd}SqI|nS{dTdW9<~A3yQ`XD zlFsL-$?*>%mqmNo1$0}sOU^N#O3NZ+}DP08v)GWV~Wi%!CV61G^-3p9R>S0${rffOY4Gna}gI@~&Tcu{u6_NcfWNDSsryVdjhx=+n8t@vB;4?CpsF@hk@!UcVPouta^QTW`=T4OmufrmmS3O$hQ$}_wXWs{ia{nk zoelKK%qiTO1^D>(&r!v?RnJ2wCjwB)y{~V?bz3D|vni}Be=UQ=F7OF9?U)1D9)EMj zFYk|=EkH5Sdb$g)XW8yTLJass`V>BG0IGi5C6xPI!oz<1A7ev~tFh?&m{;8#`E>%K2Kc`*9Y(;%(GSpyci7knM};pzx*I zR}w(!$(kfj(UaQ#;*V9M0x&&=-dhuZWJNG1kF#VPZro$LyI(AOb?)(AbqZ@aOlJ3E+*+7Yg zISD|s{Tknr`&i6iNeemG^;+Dlw+Lu=9^e*(EP(FF3DUgGzGA9eVRF)U^R zZr%Y@W7-@A%vf||L9D9XXnX+bRm>UVB`g{Mx90$EOqT^v%^4wi7=JaBft2c&ebt-C zaKm;opny7(GB|eu5D8$aw=dTo+c6KfW zQCzs=NA delta 1173 zcmV;G1Zw-=4X6o_BYy;4Nkl~hT8WJTL@SnnL6MgI zykFy)yDbT^yLa10cyY65?w#4$`TypeIpuZ^X$DvM^M60UtUM53(MqU(!K{Kf zD}ay*ClpfyFZm!;Cy&M9a9QcOyx|@jRd*R4R96JYd8lT>Hjjj9)+B6GuD{uq6yfe#rv_ zrgs>o<$+*DjDLl1Kgn2g4X(IdiLmRqSf3t3olikHNND*@d5{uYqr`a_i72%@M0{~2 zj%ndqw#44S{pmE;TL-*lqD_kkto$zk41?<4hZ(D%a1Ne8{dv*Tnt!+94zg{F&|&cgN^E=qLUmQB z_3fCsob2Aju>?Zguf({amSE@2RCKK+v-TFuWE%IxSx-9J(M)3NYNDH#dHq6-FF;J( zn#yHS+U0uprj5#3d5eWn54DqOti&2iLe@lmcGS}{Zf(H1w*~9?8LwZc@da?(8^!Ii zuol$-`hP01cL#=1e~h8h0w+6HsVwfE?+6V2PQ|v>xQ#WKJ$*O_e=el$jTZ-~h~iYn zP_i>uOl1V*+!$_mKgRCEsOR^VLJb2#t;teRCt(!zZy{>ZfdFYaz|c z#)|@|q=;&_>)jtei|XD-?9LkrJh6=Qvmao$FQNSDyNT3S6Mp3ZvOB&;?K?`eXFbk6 z&6f%Ns}snN6a`>BgF5p!44sl)4p9C`2hLqhWS`oJdt?OjnO&H3qp)QK*=@T~-;WS} zeSZU1!+ekmnU^Tgs{D9y0GXo(Pom!ahKjBCk*i6F==+4OTR^a*3Fbtxy7!WM^c@M2 zgsD7&9S=#y*d^Bke}KJ1L>g+y#lzU!KOws2HUiJB!YwcJ9>Kw$br_F#KxZpTcBOQd zXp_(hzwg%y;1~ApClTiAqZK^py(P0Ju00030 n|0|=BHUIzs21!IgR09C<&^X@&!VX*_00000NkvXXu0mjfM*lbV diff --git a/public/favicon.ico b/public/favicon.ico index d0e1d3d22d29c2cf95f626765d851358c412fc84..804106f22a5f6d043361f95804b31e3088b96570 100644 GIT binary patch literal 56045 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 literal 15406 zcmeHNiCb0G7Qe4^&OP_s8MxqpGAJNQGMJD8rQn1~rlR72%*Z5yh>AE*IFzPenU!T( znq_5aqs?XY8eiZ0i+*pdeeRLV0CST0dnW~TpripBDCw8fH2b*Kro(97v&`cowd_uCaUUc%!Q z5@M4j%0`4Z|b$(UI@G77C&)}kSGj)K*D?Z_^+qV$kW^wpoP zT`mb7w^Y=>t{`iPg4_}f*()W?TB4$`(gxr7{>SIOTdz8>^AlCzt-hcL`OBUq88WRX zKV?T`bpL%kw%Ub5-#f793mclRs7TFG(Rj&$*~KzavTPVPN$FPyjG^*)6psDm#Qv}C zn3|y=DMQ1fD+X?7=ti^3s5;@p@n787al-+-CLz+-KW?L~aknXC`}5K0{Qe zXwUcQvt8)E69Km;ys?cw;*vDXT3{bgUq=10xSKkKFbdhvB+xaVF7{8Meq$lqh>R)w zeFoo+|Ka+~!L}Qy-azle#T8tCp#F{b!S~>PhVpFmNo_Tie0`#RxxEscZ#`I4?Go#J!W0RUrmEQXjthlVR@iMcS66DomY?O_|FuKdljqfz zYYM7pt;bF0xWkG_vcsxFGRkPp&zMK^AW}iyc{kP`w825KNhrC(Dc|v)8=ar2*zu7n z)?V9t6uZ&-U))6N`~?~HwEk-;hON0MW6oNM)`*PKeO@%Z;-J{dD&|c%;|14Ux!p@T z(9lJ3->y#?hZWn&2k^Y$*teDNun%Z{i(^Vf_$2P5q|=3?6zgR!7~VXb5T~O1M;{J+ zt6}d~HtfEw3Vm=a+kC}}oaI(*>h@ySCw75n&0&4r&syj}>0S?(HadoFhbNmUUi{gE z!#_B1;2S&P(DhNWw@0=`6;bHA?Lf=5p0Z$F4)s~1n04#LSe*F9gQGt>ap*fc_J5~xufH#e~J9Ef3BxwIZ%1lHY(#Y zCa>3u&bQ<6^dDaF4RG{Fm#|53n?9!9CSi8b=*VyA#O+ehaXG#R!%zHO28=|0Pc{82`q5rrEw}ln1^u{u}R%{*7njy-_~g zyU{QAWAq((H~T-BF^zuB+MAUPrq4i(Mt!3%*L??*hw=XQPc#C)9IreWRXH9@swce{j9P zXZp|FJM$&R<*@5m`TK!67NeYb4X(?a8*_Z(BHqRL&gior^@kzUyC7_R`ZRgZd7>pP z5*E|7XbXL75Fg0xQu8D%Y^JM8!h%K#xf@9qk0iK?f_e$_>txKUm621z9F5;TCjAcv zL!UaKA#LY2>$volGb{)%H;ti^aKVUAi@U~Qzg)zJGDo}qw2Y~<`oSNFBk=z~Sh;S9`e}J8^4941YhFYe=qg;+c}O`@tWGv%mi#u$tMZ zsNRDmjXIaPhWHfbf0*B79%|Ko9sm4_9v*1xI|?eFC;Y^XC8UvD#0#-Z%->bNq#%Vj zYqN1gSs1u_^s5`oS^c?qtD86yO>k*z4(W0gR4Rhcs=H`K6Jh22 zD0BIVGv&K{GTW=}67gGeLf~f%COCTG?9V1a?|A(sW>9`(l8T)-BC+m>ikMiLctshr zmue#ST6$E+pI54YZNU8h^tlR_@6fr)De1D#84^#&_G+ZHpE|dPFB&Sg!L-X*L^+9C zN${ZQ^X%C5p$GLBDNjtCF6YBzrzluZrE_y0@|Db&PDmu4oBX`TtJj&5u3!i8za2Mq z&T-3IR>Ap;@eN%Y_aOcCi_xr2P}!Qp3Mz`|iJFlD8cdSUuAAfqy0b1@i3emqU;ey| zM~d~mb=o6#)L-ym>lM2=3&Hc%O}a0ruwwV84#8dT{J<^jG%ZIK`)&3zJDOf|5a(*c zw)a$|Jr)9gm>BzD4`|Q16z2VfpC_MQMSD`-2AU%jFN`NXxbhhJlgOvF%{eFL zUrRdObao^RemMlbkn;HDil45KP95YE$*=qK9&8Kt;mbOBo$7dsHoK9(+Cg@qoCxhz zOWO4FI2_}|OtfM}hZ9|&JF%?IDsp6;mti>u8h^O&Id66BXF4PDvF%oPpxA(8giW+wmUd8{iE@cf2jyH>lMJ7^DW|2~<#-2$k99vp z{d51j$me%`u2Nn@&;95*7u_B_uWB#Sd7{M>YnDw5k=H(Fow zq3agKRF|CtuZLma=e3i)*n=bAj1xH?&Uxv%H=B^7>o(0RmWAfl!diVUupi2N+=GS} zqJ`h#HPb?N$XL*`XKA$FW2b0%IM+@-X>{ih$7&UaV{z&?pUB%C`GIq0oI~VXrfv_m zN&dRtK0P*p;=2nmWGg%6T2;(h)q{UD=e$|pPSWYFtBE8-l*s#uoEx13JV^Oio>P2n zdBX-beZL2`PtW(D^JawD$LE%euAT$OHTxfzMDgd<$$0wrNOb?gd1-(C@_U+7B*($; zG_2U!TL#`2D!OB^lh#7k;<3{|%ftRJCBuc|UnS$2KPl()tJ|OVBRObJ9VUD1`_hhi z8&nZn8#>6KwXov@JH?@6i{HSLnCiswFOqTkPoKye@;T09ep_&Eeb+5I$MSH`Twxzp zb;b$Ke;a*>`*8e9nEP0-ajEUpG&)CnB_5~$6Wv<|%FA<3y!xEI*SA3eof{7BV=SM= zcS$m>-134Rm#2h{&Pka<#~9Vfn?BK#_!ZQ*NS#R%aaip*iQ;q~19a*{ZQAkM}7 z6iwgA0qZx+{sy$kDD+plJq~C88$~ksuzagmaJ^%JW2j@UXo@BL(P+8oqnP;brF)jS z?wLtwe$j_S;!cLDgL~4>#2|2)X%gp`G`|~G!=ZCp>9&^sQuh;AO+Rp2B?p-qzJzl&79ss~=pnviV09f`d z7I0zM7yX)^_W-~fW~VK6**^jF-xq*6|91g69^U^A23UdshDQLawjbic0~`)}SQ*2| z2NC!nULKf_05~v^f-K+!I9DnVByf3}C^yCeZcdzxB2LDT*!u)<;{IEZpNqRbeD8P$ zZ)Y6$t4rMPu5ool%MYi5&!2Gz6;=ME9v*+g^U)uBNP(l&AD8XPSaFU}`a&O%?_L}xg}1Xpr{;|q;bP8X z?poq?6 z;2DVEj|W(~EAGuz%rh!Sk-yGhCN{+xo9Tgl;VRe~%f+B^mfpl9JMq>>U>}|5t_juX zNyC2*#XP-m_)oS}$J4#Po~gDz-rtqHyZgCz|I3}7i7FjW#oHfi_q^EsBX0N4C#v1a zQmv16G(O-i4HEl)Z&$+ud}Ab6MbNg|$X&H}`73Gs&G$vC!_18cY@GnFe9xFz04(QD zJnwM{0d++g&45$-htHaXX^V+hpJh2H>|l8c#j`e9U!N^(2%KMJeR1B}@Yq}!U7QrK zTFBY>H*q6!=of2tIHBzcvnFsTf90N7-utA&m3iM`)@)nDPyevFq7dJ?`F}+z& z+BUnJDj(A6Lj{B3Yb%!uhlc%!p4Y~>_to9sn9gYZ-rJrt{Bv%0{NKXY*@4d=I=}qp z|5}t9ow_l5$K5stKzCt-lgBIrx@W3G|1&Ry&dd?Zf&aVx|9z+{v+uMb)T1#aN2GuM z{Tbnspb)XcSS};VNw#bv-P2Axp+!GT;q2{;+!p3pzG3buyHta))|-&r&!p0>a%0!8 zGl@6+lgfxVYIZ>n`z@a*d(oqY4*s0eA%S74(-c5#dl1?0kYgx z0Xe8otd`6?1`H3o(D`)tLYayAvSe*Mu({tfeShZi^g;il!b_azuknF=;iOLK-~MG4 zOhdQB692Rg$wSB;?IYC_-E|)e{vNr$TW`x)wH*M_i`NhKUX~gt+jI`@{2nZm#+e0x z(t9lvTc7>cU3I-&+nSoAehmA`V8Gj&>HEchZ$4^x^&{DH&eNOI?UpzDK?FCFMyZ>- zdcxDQX}y)j`}>I}VPF$c;pghSI9zRKsGUFhyLu(w;^iNTQtfT{tjL5{TvRDq&<)T4A?H! zyWxTD8dQ`u3JF(;X(2Hl{1Z+MiZxATk>uV!uI5!Zqrd~P2Vs=cI-@4K2 z12nPFn%iZ9*Hzl1xb{CEhlhu6&6I4Ye^?vKjr!3Vo^>3Qj4v12rTFjOH973$9CZ5e znTq>)o)?6~;I$y%i{UEvY{@wzeBSpRA~ z?4ZGR=DxFv@fucbg8pg_b4N9c9J`g-$k=`Oopif1l|OU*EIBsDR>!|Blk$O zQn}=wQiUiB06LRdx<~r99o!Rg=78}5_gz+x+C%$#I#uyH5@Lt;igPiP+(`T4Rj_jR zt*k7jv&GEX!Yeubi;KfQSDw3&i~-uZ_Kbk-D%gy8AFBiT7f3uH$7{HGJ!w_9kIxqd z+Af!QOZ+S7k)bEwzpcF}VFVuRqn{*y>G!#K>sC#m1Za?3WL3( zAlw6a|D006sMv93A+F}WCID&4F{5f;uXJ^vSXGotfnAwrEpW%Qe-8AC`s>Uulfcp6 zM=Vy{_!C6TbEfW$C0+yBZKssZyv{@2dv1Qjq)>qB@52^X2}iav!xeX-KcQQJT zh>_2LXf_e*cBY7QClqgP^+*tNf7~Hy$m@$f5%_x=&wkqx7}#c@o2kku_reWJ&RGRJ%7pMC z>w;4t=ye6np`(=-0D{khX#d#(G(hLOxO)mw^$G+r>+&^Gv@EYci$$edK0Y@AC0PKsvY)E{@w$P>e=LGS#=(`0+C z69VxCcCJdH!r*4a22|C{c~)aj{P%nlIzJnskbvNEos~+_$Fd^O@#sW|5dqUUz%&*= zz4A8so(T!)*|8>)a|snZnNjIPr+>Q_#Tg++KN(WLxO*emf6FI_wv42{Xwcv}3DH?n z0Jk4<-%pp#mjLX=W4dgayea-1*VC01b*=8V@*~P^z4q85{xjmk@6I0u_p7P@l;t5as!$!Kjtb;|9n&; zZt|2o5s@~D6aaV}rImodkLAZg)c53^834{_@ske*ovaU!LsAA_b-KzQmO$D+8| zHYI~SGHnkk0JtCy#kq!23hdsgVMQV$Cld_Ra{NR#)Lz{xZ)M9D%Y)cn(#-^BuQ%o$ z;K9x&q}4ps6@JjeI5&ZXw^kLvg=8CMvl{BbS@8jGW>xPd2K zT;41-1!(s@(9y+16*^`^fY*MQgPD?4vuL33^dBw#!k~08q9!qomGFmnHA61^l5zFUI_0u-^CyeZ;%(M*RCth2=wA-;2GH2D1l&dwwoF= z5NLS@l^(1sj)dtxAkq+=d9VCXSR9W`!R{C9e+v9fl*A*=aUfUn9(Is*9EiS@4UO&$ zMw`putV^x{;2uq6*zW;7TNAApO?j}d^{MpC6ejEmLl`|kM9-R3_dv(#~ zo2uhst@)AVXJS1*C*p(%NNzqTX0jKphDj4S1k%Ea$S=Dp-(r=IM0SwXaG_jne|=?~ zoM3ny7@o^WgbTgHO8ASLvC__E!$j+osqz=YlzU}7YgZ+;hNR-|~~R#EhG0X$^}gI98E z-hS5{Vh@mpx+dYlstx#lzX6CKB6EAqT?R{?7Hdr+*nmE8g%StOkTQHx;wt)gB3Scv-t?TILdy(y@(BX%1}=? z0-2w`m|hbhj$uVjn)}GE!rfR5eVgOnI6)*$7%_i1|Jk}-@#);n*T(aY{~Be+NJj1l z;JnQD(1iOw)N%zZog;i7HzfYgYDfusvYImcJLQKA9w2A)fwL*)gN^cYIb@H;>%Bf= zbWPT&HR)?RZQduBQS5O2pJFtt!2}04QVxq5fLCW;17`vyB%J@5^jeK~`WUG0gp0T@ zq|ALOQ3t;1R=`|QU1$uo!g9z+{S!+Tm6U=HM+(ATXTtIY{)tsrg?R+W!6Myf=v5iKR^&7$*7&`yqZ4;_IjA(4jfmeI**}tm7Q_cN|2O0i{ z-($R!)lAkWdxdHiYvKq1(X9WC90&UZ>eFPgbYap<+TJax1WxbkU>nvO&I?*>ePQ)D zBHdIQbknhewQ%B))+;!J2YLTCUkedxsXG4WTc3sPe+wOr16My$p%UIU)#rY7tCL2r zV4f$CVt4_vs~Dte!`LNz^7_T!6O!owOu*dKv4*Ri#oHf(L|@3}>>E&`7$wYrxo&@E zb|#Ir(!qb$90rwgd#TMGaDvvX223Y8Nxu%i^AEAsf*sGr2~y?+R0&cMpQ&I8!FfAh zbAZ%oqR19*v1We9564fp9Hm7#ULNEEWfVBT-|hXSZaW?lmRJO3C4_^h-H^EBIkOId z085D(KL?vY*E5`lurM8W93)-o^ldT&9UjUc%w6#YM=1vtoJ6A#TA5Jt+ znGobd`0_z2xp;eK!(ECh4`mdV_|r_b2dCF&@2dkh?CQ$V?&1;UM{Fi;>w`j8jwoYU zfor1vG(B6U)k`JkU$UcO8aley zA!Xxo(Rpc1cBdH#uQ52rGnShaR?A*B<=6>%W3u9G0>>1%zPt0?LE9X@@0_hZUj8bk_W2w3UTkOr0n| zPVaY~`2C<~K+LFE5b0<(ogEXTKPez%y641tJ^5fV1Jnm7=V&K5z_n-_tpeGZLA0|&TQdr)qMx;7t!BMx4heKI$9 zUhC3?_DKlTtAEg0`E>KbP{&-iTxe{8jM;(X@$j2nrUQ+!PkD~T3pBzL!8fNGvu_CF z;5f(Qkj8N+XmKb6F$DLzBhT*>%%(!(p`N^-e^X+MrO7>^94~-#0yM1m2x!*9*;?<= zc`0>ca3Dfz>8BhR5QyWP8c0a%?)x8w4Twz2%LGHHB(v!z5@JF>BrT!H-Lv&%TisH zXm#EY=iM2q38ydzS5O^~>lJ$=-O&FA-(QkGd~sV7u4pHe=)p zva_+`_Wde&U9nrvbB8ay zj2hCOy&7Fs{6*CxovNjozf*MPh@s`oJ*q?CT*XJa!b(VY74@8KqjWxTY+vL-uFwY) z;pfy9rIrRaE421|-MH~9JF-!-Qrh=Mw7KcraA4J{5(tlXH?LbB&n8m17}=NOVEuFZ zkFo5*o}$z&jAqD{l6?W)eSa%15&`(L^L+1(&zFx1N4Y`nehyRb{*u$U>n}JzCBFg- zu^EvXjupQvLf<;m^OhGjoWv5O=DIW4I6Ir27!=6Mmd;i|IE(=7&t)dX>}zjtmm3vZ zOb4EoNWtHc7b5nX#Q=myyRWWPG+U=Wgz3D#Y^#ZBd@dxug_oeMi;~#K`0ru)YoW%NV{(_?D3C`C_SD}FI zq2pYXT-q(;d94kpTY`s{MP0N)U43eiV4-_N9F}BxGUAKsryw7P530~PvYExV92{#q zBo~aM&?pB=!D-!Xg>Hl4vQIF8xLfvX@cDDFdI_j(Pr}Q%-5#-q15H>l1W%C?!#fkQ zjEpnzhmDQ5FZTL!U`VG=mVZ5D9K4mG=){9u0YNxY7-G!Lc6Ai1s-zBoleY@hbTjbz z7QDmO4Q+VO-NX&X1mxH z;^Z&CrJ_GsyU*%F6J;qdBhCP@bO1|c;P73FGki^?Ypp`L(y{y3!4!1`VPu|orEki2 z^n;^>4308E_^JnH#VN-nC|_{FSZW_8NNd>1`rU6by#F@f;%(KuqT_J%<2gG>T^UNe zDHOW33hsv6tqWd)Ht2@1g?ZI;mnzrOWOWH+2ypQ{l=*A;owGmtb&E38vI{U{ujHXL zVMLywaP+2QXt(?Gsn!l2832xB-e6Vdw7gcCr4dme%9SDqQeN!C=hANeHPx06#co4_ z8)BtRaw+GzkY${@+dw6|q)!x0e=E4wy3}NKgY5locK-w1st7_;L4vph?}l3g1FIPz z(Ntaj45WT!+r@47a+B+`sgk>K^LKucPdt2wT-O+!#98iC8I;{q6CRYnz|j+5-yCfo z=J}hpQve{|Ed_sDFqa2@9gJsmyIlkMad2G20MFkI7nzpWj4)w}VHWiAmf_uj-vMRV zVK%qiZ?{kUuHk=x&l0rZVl0VK$`Bx!Th1W!&^Lbm9)9)iAvd6KMHSBUhb+C>F0?j?bRvp{4yu?7A~$w;B6dubO%QnP{dNMR1bBi{qpyG z@RZ%}Iql4KA4nbwpyfX8&_2xHRhW((s*&KC%|f*?GFiJyDFRWu`Zw23?}N^uhwM6&j0N3S28y9y;92luajk}&U>P{Fb7pufV*~THDLL+t&z?X`d$*K{#lT{*q)VD z@GSZd7vsq}#-SIQY;2=(YdoY&?ELfy$C*5dplbTT8Qj52oII+cOT& zG}P}_{ivxNZtH6^Bx?54L288KvLT&FdCpDgyC#-$4B~oPzn1=})hjh$nHt7Jal(X3 zoZK?SQ&1kLz7I{qG2TR5OcoT(>HM0BWIM_y?rFI%e;hqpGldjH)T&qJFRSrDYZPEF z01vh?v<+@`!TK%h1G{Ci-@S`}|mnt(XUAa_@4ZqoxshVRK?lydte}2(ePH!yURi(c8gB#=k6PSI!c=& z9{$k-Q#cQ6s6hq%5utPoA{9$QMYNqDq6^Btmzw(N)?j!s7Yq}{9}eAkXF?7+z6Z$i z*3(0&&E4btKQxH`FXuj8uUb^P1g&PP2OdxSjHPFC{5}6mNC$x1VScd0we``*ibn;s zAGa2ksWr(WPrgg4-&iR~73arNybC9fIEIY!z9Eom9s}b9U>P4=??D~!o%@D;!ZVPN z%;6{pJP7qHk=;n^`|w^0lA?BP)C-9#DXp z+MrEVgO&nk{Q-Wn9|u`)_HsuE5K-R7Yp#CU)0$RP^b;3$Q>O0dn!;FRmmmzR8`=oCEV z${BF?bJQR`%REkmBcp%tB^$`wFI>vcj&7#)pK}h{N3aDC+KbZi6_E+IHaL4>7A0%n zL~=1myg0=c5xxWchrBaa6C7~X>eYfQxUCCDlu5@}8><@F%(Qhl08PaX>IS7Sx4D8n zNG>~(YBI^Noh*CRW@^>c z++ml8E{rH6gftulrk?}MO(o4+iu|Q(%zp@zohzYesnnM!GkdN<^T^y_TL7LX4E=gl z_an93!qHuVSub(kz9JVb2ZCR!@DyuL=u;05dv7>%vaIe_)47t(qiPuEX>!8`Fv9~q zu|Kqk%L{EM#?W&CSGN@E>r9}fL7fo)8{i{i1QfXkyUHu3*(^Wb?t!Zu9UFUH`iT>S z!Qh?>=-55JmL`v2|LjCoqxoY%IALPX(Qx1^gWNY_$I0hO`q#|IgKJDLiHLCMg16g2 zj6F0Xg4{8Mkt%T12Btr#V&w3fyeS5!F4&d9?3&ljyTTu@l7~7Tye9ZT`Defggd9z3 z1}nypFigZ}7v-@$kFu@#_2F6ucK*t!C>KCnGe;kFwY0_lHDP?h4}Q2P24%0q^gbHv zA8HNd=LO>CgWW;DPE;0(T2wa^-!`_3f00Nm7PL4^47!Z_QR+XN z`up2yt(*^U_{a738R(Nt$F){dug@Ly+a2Fu@M1h>GUu+q0ba=n=mHUr%OMASu||hK z0OFr&dR>~NIg?)%4uS%5n_y}piY&!Eo%PSm;)`V-O8{F0(9g^X+2RcC%42u1uGs*> zBH1bXW8kpfobgDA{p~@u0az9au-*ym`mOPdea3ACl9AX!Z4r1-64bP(wTE2^&g&2K zv^2paz-tvJMJQ943!?<88HhQ*xHD$$+xPLs%maGb&k-MRPLw?($@h9)Tb*h2F){F) z<0LOeS(gjRw^l=R9YE$eV}MZx5jkp;Ugyh9f)79+C=Dsd=q~!;5o)+5NU-jQ-!^W& z;jGnu#Z)l^+>})4co0;w`1|Lpk1^!`*eiS3-`6AO8alL%8V}~j9M4-P1EdH~F}DJ= zD!;9x|4|_6foVUw&h#I*+-U}`Lg`>j1mS#Wa0P+NPd9P{K}30}A)zXOAqBXF0b#+J zooCy>So7*$X14#4{Imwe1IAHOwd>$9=Mif>rTiy1X%|q+%MS+=rsvq=bBOD%$jzCn zuMsL8-4z{tkb5xQ10K04jD}0EUls;Ii8pbU7^?_kC%bBJwYs7C0}#)=nz7wh$8@%0 z_3u6WU|KbvbQJ#(rVJ4%ufXl00S%@Peb}E{#1`by%Q5}C{$t{PtzSxTU~HQ9)9tDo zS19+rd59O7a!Axcz%YaubyXsWdEzQ-NRd7CZu_s|D@;x|pB9`xH+5Sqc$x=oz}^OM zLJkjbB*yO`CsQGH2N|SAl!B)+${+q4&q&{H^l~8oI6REGj zm;JrG?cASO+(Kpt!540>L9zb`inMex>fDTDXF)^;Odly5;-X8hOPSro4*IZ*Q~wSi z2Rl}*MM2}@I(Uk!y08amO*6mzhge*MCcO*mTw-$)%Er?TnGN|_=9GOiS7_421Wd*o znjsMF2WOOkBIcg#F*A_M3lEC?$5rD>la6UMH~r2o0?i6a7K;ORg2+28RS!?yYY1Xo zWEF7~7lu33c%2V%oI0l1*8A(a$E~UB$(S>i2}r{-tuqTrTVj3Ov&CH_2Ix0f^Hx%D z;c1Zt5d(_Zac$1QssB{*U^_N{x^gndPT&v-S43Eq4u^TL73gvY9t^WPYwO|=A)gK4 zO2$50E#C?bV+y-gB0?If%7_3;pE-f733SH|dfLPCFa%L|&B;1SmLyf&BLGTqEfnDO z-_&0j`}mJK|}?h%pH|<%n=GGK3mT>Z}%Tn zIWhvMmzdIXRFDiNegIi#7N5ziv5N&_etYS1B4yd+$1qjZ6;rhJe0Ou64 z#Sq%m^jp@{R3}(q&OBRI`8#)bssD?@C<=>E60k&i!Frku30hl5=Sif7lTiI9TD~B4 z<7&S5PEut8+;XL7zV=7uO=sJl6<2{Nz~`24p(mQUU7o4VLt%0A>1hej3=wpqeaOE{ZNOs}p*45v9pom`|D$4c!8 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 z zn+jj42)BQNi%xw;r5~DQPEZqj;<)@X@2GDHycw_G(?33NisMz2;$&*=Xd(lAoH8ao zqKEeM`0#D1oEul2iq(+xYTKGbWjzPhX)k8t#gdX$gmk825bPn|pzym&o1159TH%$ZrU-X1@cmB-( z#-|gsbb7gcbLm{mvio+>ORdI52}CTp)v@zoKOf?GiWcvZqCHye9&1OH|Wetw7S*(MLi+V0{ei-Kb#qY#ZU<^m-H{QPR+V|DAqlE;^vw zKK7{@t$=1P;y-9~^=tYMjfSas=(`zxCeO(rpfvQ9WH{mQ`IbKikSaWN)KpdhpLuw| zxqbSr{#MAt8el&hYJOn~=oC2W~&?M(T{2K?^Ka!kioqTsRT=h~djutod};@eunDmrla8xXcz6p1;dsxXY&vkSxQdv1Jz~Z{tP4ca zux!E7v$u1*P){O8$0O) zv5WyZ(g8GM@_d-E;@UJC;e?w~x%tX8%3lkSt zTi!p+XWx%i-eZ}~CjD=hkXsYkXO`aAG*7A_e_kxKkj%g!IpS#5?iONib_hrZPl7gl~%&za-}}dp3#@ zAESqEg6Zi2@1w|_tV(h!I=jO#!j`@uLL?kD`E3HGS~SmkLoY+gLTatc6|Wi6nU0_2h3LXBUbLxaM+kpBIj5LZEQVYELSr3C{tcSr=*yHcMINdz;np8FlH^-K|d zoHed>y-g_YoWzQy{q2{aBhn`S>C6*)h3hL2AX)|FwHMzS*;V(u&QlmQju+h189LRd zLm6IqU~K+&Ib5Rh)FJ{!k4MH~m(3b&(!`1?CB3=F@0n>a zDMCl?uZoF&Entb^eeYkt=9JavHaqQe z=d$!?zhcn4;hM@c;Y0ry?xigu+tq07g~bxqH{_n(vnvQ2C}U%zU+{@7;!5%U=agICbTr z8|OEc4dOoGpFa5MUo~;f7Fo%Cb#bjgtJIOh>FXFOJG+fortbc?aa-{YiCIQcZ9X<} z{{!D2m|=D?YyOWpJ?qb%Wpi}!Wei!^mlMF1d4B_oue!ME=dQzEs`KMH^Kv&#W6rOs zlK!COn(_bEpCjg9pT(SITe9=|Y6+8{rYWbh8vz3P~W|-`m9J75UFSE17Kl!io{Lb%mU7UMr`q?z2 zg&}`#*x!}>zh(cbUA1$#c>8BX2sL<0@h$aGs9W^(;fig_ed>ujY|l?&P@8nCb5Z-d z8UHPI%AQKuWOx6_0)vLy`YZas%0I2pVEFvt-JfT(&&}Cmxl-BM?6^+Z#j}S0Qcf;N z_33@$a`{og_bt*N&+Iv!VX!eK&r*WR#V=$z`||frwt=t9R!1@{5;+)MzoLGD_<_%V zUcOwJvTdhM>@z9DKBw17i_9f^l=Zypcf^=oD~hgl+3@|5OQ&Dl(ZU-)-OoE5KfUN? z)*HRm3LC{YzqmW;fPlwx!Bc8~y)6PGzP8VsUUx_HbWr)pPZrCQ{apn*ugGtbN&L4d z_Pq7(x@Au|>Yn${o4t-b&QIn4XQxNitpZLQiY+kWZ!$k03vg`_aNsIl+7Wmm3KD3w zwGaSFF?_1eWkC|hWG=O}04`c$U=aB7f2*+$hy?`))B|es7RH^K8V3^aboFyt=akR{ E0P0QXL;wH) literal 0 HcmV?d00001 From c623e27f7f05a6bf584ec4d4c2f96ac3d2b3346d Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:31:22 +0800 Subject: [PATCH 15/40] fix: derive current analytics section from pathname directly for sidebar site links --- src/components/dashboard/dashboard-shell.tsx | 28 +++++++++++++------ .../dashboard/sidebar-site-details.tsx | 20 ++----------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index cc793fca..08f9b8b3 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -326,7 +326,11 @@ export function DashboardShell({ const mainLayoutSegments = visibleLayoutSegments(useSelectedLayoutSegments()); const mainSiteSection = mainLayoutSegments[1] || ""; const mainSiteSubSection = mainLayoutSegments[2] || ""; - const validAnalyticsSections = [ + 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", @@ -341,13 +345,21 @@ export function DashboardShell({ "campaigns", "funnels", "retention", - ] as const; - const currentAnalyticsSection = validAnalyticsSections.includes( - mainSiteSection as (typeof validAnalyticsSections)[number], - ) - ? (mainSiteSection as (typeof validAnalyticsSections)[number]) - : undefined; - const routeState = parseSidebarRouteState(livePathname, activeTeamSlug); + ]); + 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, ); diff --git a/src/components/dashboard/sidebar-site-details.tsx b/src/components/dashboard/sidebar-site-details.tsx index 463f9222..766d12a6 100644 --- a/src/components/dashboard/sidebar-site-details.tsx +++ b/src/components/dashboard/sidebar-site-details.tsx @@ -60,28 +60,12 @@ interface SidebarSiteSummary { iconPath?: string; } -type AnalyticsSection = - | "realtime" - | "pages" - | "referrers" - | "sessions" - | "events" - | "visitors" - | "geo" - | "devices" - | "browsers" - | "performance" - | "settings" - | "campaigns" - | "funnels" - | "retention"; - interface SidebarSiteDetailsProps { locale: Locale; teamId: string; teamSlug: string; activeSiteSlug?: string; - currentSection?: AnalyticsSection; + currentSection?: string; sites: SidebarSiteSummary[]; labels: { views: string; @@ -105,7 +89,7 @@ function buildSitePath( locale: Locale, teamSlug: string, siteSlug: string, - section?: AnalyticsSection, + section?: string, ): string { const base = `/${locale}/app/${teamSlug}/${siteSlug}`; if (!section) return base; From 819b21d0bcbe6bde370ffc3cdf3b825b47b09f01 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:37:45 +0800 Subject: [PATCH 16/40] fix: allow day/week intervals at range boundaries (90d, 7d, 12m) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range presets using startOfZonedDay produce spans slightly larger than their nominal duration (e.g. 90d = 90*DAY_MS + partial today), causing <= boundary checks to exclude the expected finest interval. Use strict < with a +1 unit buffer so 7d→hour, 90d→day, 12m→week all work. --- src/lib/dashboard/__tests__/query-state.test.ts | 3 ++- src/lib/dashboard/query-state.ts | 11 +++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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/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"; } From 0a9d5f0f6f89c29aefab0f0ad8be4f4af921a3ec Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:54:38 +0800 Subject: [PATCH 17/40] fix: apply current analytics section to all sidebar site links, not just active site --- src/components/dashboard/sidebar-site-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/sidebar-site-details.tsx b/src/components/dashboard/sidebar-site-details.tsx index 766d12a6..c4ada45a 100644 --- a/src/components/dashboard/sidebar-site-details.tsx +++ b/src/components/dashboard/sidebar-site-details.tsx @@ -418,7 +418,7 @@ export function SidebarSiteDetails({ locale, teamSlug, site.slug, - isActive ? currentSection : undefined, + currentSection, )} > Date: Sun, 28 Jun 2026 00:01:44 +0800 Subject: [PATCH 18/40] perf: aggregate geo points by rounded coordinates to reduce payload size - SQL query now groups by ROUND(lat,3), ROUND(lon,3) (~110m precision) - Each aggregated point includes pointCount field for weighted display - Frontend clustering respects pointCount weights - Mock data also aggregates points for demo mode - Reduces typical 5000-point response from ~650KB to ~30KB --- src/components/dashboard/geo-points-map.tsx | 9 ++-- .../overview-geo-points-map-card.tsx | 1 + .../site-pages/geo-client-map-stage.tsx | 9 ++-- .../dashboard/site-pages/geo-client-page.tsx | 5 ++ .../dashboard/__tests__/client-data.test.ts | 1 + .../__tests__/client-geo-data.test.ts | 1 + src/lib/dashboard/client-geo-data.ts | 4 ++ src/lib/edge-client-types/overview.ts | 1 + .../edge/__tests__/query-journey-d1.test.ts | 4 ++ .../__tests__/query-journey-helpers.test.ts | 1 + .../query-pages-overview-coverage.test.ts | 1 + src/lib/edge/query/core-types.ts | 1 + src/lib/edge/query/journey-geo-queries.ts | 32 ++++++++---- src/lib/edge/query/journey-helpers.ts | 1 + src/lib/realtime/mock/utm-overview.ts | 52 +++++++++++++++---- 15 files changed, 97 insertions(+), 26 deletions(-) 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/site-pages/geo-client-map-stage.tsx b/src/components/dashboard/site-pages/geo-client-map-stage.tsx index 7d18595b..632a56ba 100644 --- a/src/components/dashboard/site-pages/geo-client-map-stage.tsx +++ b/src/components/dashboard/site-pages/geo-client-map-stage.tsx @@ -32,6 +32,7 @@ export interface GeoClientMapPoint { region?: string; regionCode?: string; city?: string; + pointCount?: number; } export interface GeoClientMapCountryCount { @@ -330,9 +331,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/site-pages/geo-client-page.tsx b/src/components/dashboard/site-pages/geo-client-page.tsx index 14cabd2c..8848b066 100644 --- a/src/components/dashboard/site-pages/geo-client-page.tsx +++ b/src/components/dashboard/site-pages/geo-client-page.tsx @@ -76,6 +76,7 @@ interface GeoPoint { region?: string; regionCode?: string; city?: string; + pointCount?: number; } interface GeoDimensionCount { @@ -336,6 +337,10 @@ function resolveGeoPoints( .trim() .toUpperCase(), city: String((item as { city?: unknown }).city ?? "").trim(), + pointCount: Math.max( + 1, + Number((item as { pointCount?: unknown }).pointCount ?? 1), + ), })) .filter( (item) => 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/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/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/__tests__/query-journey-d1.test.ts b/src/lib/edge/__tests__/query-journey-d1.test.ts index c888a9cd..5bcc92eb 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 }], 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-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/query/core-types.ts b/src/lib/edge/query/core-types.ts index e19d9400..d48b79dd 100644 --- a/src/lib/edge/query/core-types.ts +++ b/src/lib/edge/query/core-types.ts @@ -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/journey-geo-queries.ts b/src/lib/edge/query/journey-geo-queries.ts index 8d72e4a2..3cc2ebba 100644 --- a/src/lib/edge/query/journey-geo-queries.ts +++ b/src/lib/edge/query/journey-geo-queries.ts @@ -71,24 +71,34 @@ export async function queryGeoPointsFromD1( 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} + WHERE + 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/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, From a1133aab2a3c96e01a81716adf7482e9f4203ee7 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:14:23 +0800 Subject: [PATCH 19/40] feat: add 2-column medium breakpoint for overview metric cards grid --- .../site-pages/overview-client-page.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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); From f003c30e7036e774513f506dc3f133c1d3730ac3 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:16:28 +0800 Subject: [PATCH 20/40] fix: use project Spinner component in map loading fallback --- src/components/dashboard/geo-points-map-island.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 (
-
+
); } From 14e25594b68c9414333cb14cf3a9226c48e27faa Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:25:10 +0800 Subject: [PATCH 21/40] feat: public link system --- .../[teamSlug]/[siteSlug]/settings/page.tsx | 1 + .../app/[teamSlug]/public-links/page.tsx | 125 ++++++- .../[locale]/share/[slug]/browsers/page.tsx | 28 ++ .../[locale]/share/[slug]/devices/page.tsx | 29 ++ src/app/[locale]/share/[slug]/geo/page.tsx | 22 ++ src/app/[locale]/share/[slug]/layout.tsx | 40 +++ src/app/[locale]/share/[slug]/page.tsx | 40 +++ src/app/[locale]/share/[slug]/pages/page.tsx | 26 ++ .../share/[slug]/performance/page.tsx | 24 ++ .../[locale]/share/[slug]/referrers/page.tsx | 28 ++ src/app/[locale]/share/[slug]/share-utils.ts | 36 ++ .../dashboard/dashboard-header-controls.tsx | 23 +- .../dashboard/dashboard-query-provider.tsx | 83 +++-- .../dashboard/public-link-copy-button.tsx | 35 ++ .../dashboard/share-dashboard-shell.tsx | 86 +++++ src/components/dashboard/share-header.tsx | 312 ++++++++++++++++++ .../site-pages/pages-client-page.tsx | 25 +- .../site-pages/settings-client-page.tsx | 235 +++++++++++-- src/i18n/en.yaml | 30 +- src/i18n/zh.yaml | 30 +- src/lib/dashboard/client-request.ts | 40 ++- src/lib/edge-client.ts | 18 + src/lib/edge/__tests__/query-journeys.test.ts | 1 + src/lib/edge/__tests__/query-router.test.ts | 4 +- src/lib/edge/query/entry.ts | 12 + src/lib/edge/query/router.ts | 45 ++- src/lib/i18n/messages-types-analytics.ts | 4 + src/lib/i18n/messages-types-management.ts | 24 ++ src/lib/realtime/demo-site-profiles.ts | 28 ++ src/lib/realtime/mock.ts | 114 ++++++- src/lib/realtime/mock/admin.ts | 5 +- src/lib/realtime/mock/team-dashboard.ts | 5 +- 32 files changed, 1485 insertions(+), 73 deletions(-) create mode 100644 src/app/[locale]/share/[slug]/browsers/page.tsx create mode 100644 src/app/[locale]/share/[slug]/devices/page.tsx create mode 100644 src/app/[locale]/share/[slug]/geo/page.tsx create mode 100644 src/app/[locale]/share/[slug]/layout.tsx create mode 100644 src/app/[locale]/share/[slug]/page.tsx create mode 100644 src/app/[locale]/share/[slug]/pages/page.tsx create mode 100644 src/app/[locale]/share/[slug]/performance/page.tsx create mode 100644 src/app/[locale]/share/[slug]/referrers/page.tsx create mode 100644 src/app/[locale]/share/[slug]/share-utils.ts create mode 100644 src/components/dashboard/public-link-copy-button.tsx create mode 100644 src/components/dashboard/share-dashboard-shell.tsx create mode 100644 src/components/dashboard/share-header.tsx 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]/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/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]/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/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/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/share-dashboard-shell.tsx b/src/components/dashboard/share-dashboard-shell.tsx new file mode 100644 index 00000000..15155c86 --- /dev/null +++ b/src/components/dashboard/share-dashboard-shell.tsx @@ -0,0 +1,86 @@ +"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", + "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/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/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/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/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__/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-router.test.ts b/src/lib/edge/__tests__/query-router.test.ts index 64bc8085..f7e340cc 100644 --- a/src/lib/edge/__tests__/query-router.test.ts +++ b/src/lib/edge/__tests__/query-router.test.ts @@ -119,11 +119,11 @@ describe("edge query router", () => { }); 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); }); diff --git a/src/lib/edge/query/entry.ts b/src/lib/edge/query/entry.ts index bbc1a2c0..80b1eaff 100644 --- a/src/lib/edge/query/entry.ts +++ b/src/lib/edge/query/entry.ts @@ -1,6 +1,7 @@ import { 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"; @@ -55,6 +56,17 @@ export async function handlePublicQuery( if (site instanceof Response) return site; const segments = url.pathname.split("/").filter(Boolean); const pathname = segments.slice(3).join("/"); + if (pathname === "site") { + return jsonResponse({ + ok: true, + data: { + slug: decodeURIComponent(segments[2] || ""), + name: site.name, + domain: site.domain, + id: site.id, + }, + }); + } return withDashboardCache(ctx, url, () => routeQuery(env, site.id, pathname, url, { publicMode: true }, request), ); diff --git a/src/lib/edge/query/router.ts b/src/lib/edge/query/router.ts index eeba29ec..0a5789b4 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -53,6 +53,47 @@ import { handleUtmDimensionTrend, } from "./technology"; +const PUBLIC_QUERY_PATHS = new Set([ + "overview", + "trend", + "pages", + "pages-dashboard", + "referrers", + "performance", + "countries", + "filter-options", + "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", +]); + export async function routeQuery( env: Env, siteId: string, @@ -80,7 +121,9 @@ export async function routeQuery( ctx, ); } - if (options.publicMode) return notFound(); + if (options.publicMode && !PUBLIC_QUERY_PATHS.has(pathname)) { + return notFound(); + } if (pathname === "funnels") { return handleFunnel(env, siteId, url, ctx, request as Request); } 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, From 57972cb3af1d692b112f3bb54784eeccf5b68492 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:49:22 +0800 Subject: [PATCH 22/40] fix: fixed the issue where points could not be obtained when querying the geo page --- src/lib/edge/__tests__/query-journey-d1.test.ts | 4 ++++ src/lib/edge/query/journey-geo-queries.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/edge/__tests__/query-journey-d1.test.ts b/src/lib/edge/__tests__/query-journey-d1.test.ts index 5bcc92eb..0bfaa59e 100644 --- a/src/lib/edge/__tests__/query-journey-d1.test.ts +++ b/src/lib/edge/__tests__/query-journey-d1.test.ts @@ -640,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/query/journey-geo-queries.ts b/src/lib/edge/query/journey-geo-queries.ts index 3cc2ebba..2299705d 100644 --- a/src/lib/edge/query/journey-geo-queries.ts +++ b/src/lib/edge/query/journey-geo-queries.ts @@ -67,6 +67,7 @@ export async function queryGeoPointsFromD1( ): Promise { const filter = buildVisitFilterSql(filters); const parsedGeo = parseGeoFilterValue(filters.geo); + const coordinateClause = filter.clause ? "AND" : "WHERE"; const pointsSql = ` WITH ${buildVisitSourceCte()}, @@ -82,7 +83,7 @@ filtered_visits AS ( COUNT(*) AS point_count FROM visit_source ${filter.clause} - WHERE + ${coordinateClause} latitude IS NOT NULL AND longitude IS NOT NULL AND ABS(latitude) <= 90 From ab68945c23173f0f4969a5c2d6e1dbb1bebb1a17 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:52:29 +0800 Subject: [PATCH 23/40] feat: new public tabs support --- .../[locale]/share/[slug]/campaigns/page.tsx | 28 +++++++++++ .../share/[slug]/pages/[pageKey]/page.tsx | 46 +++++++++++++++++++ .../[locale]/share/[slug]/retention/page.tsx | 28 +++++++++++ .../dashboard/share-dashboard-shell.tsx | 2 + src/lib/edge/__tests__/query-router.test.ts | 19 ++++++++ src/lib/edge/query/router.ts | 8 ++++ 6 files changed, 131 insertions(+) create mode 100644 src/app/[locale]/share/[slug]/campaigns/page.tsx create mode 100644 src/app/[locale]/share/[slug]/pages/[pageKey]/page.tsx create mode 100644 src/app/[locale]/share/[slug]/retention/page.tsx 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]/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]/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/components/dashboard/share-dashboard-shell.tsx b/src/components/dashboard/share-dashboard-shell.tsx index 15155c86..88da31cf 100644 --- a/src/components/dashboard/share-dashboard-shell.tsx +++ b/src/components/dashboard/share-dashboard-shell.tsx @@ -23,6 +23,8 @@ const SHARE_TABS = [ "overview", "pages", "referrers", + "campaigns", + "retention", "geo", "devices", "browsers", diff --git a/src/lib/edge/__tests__/query-router.test.ts b/src/lib/edge/__tests__/query-router.test.ts index f7e340cc..7488422b 100644 --- a/src/lib/edge/__tests__/query-router.test.ts +++ b/src/lib/edge/__tests__/query-router.test.ts @@ -127,6 +127,25 @@ describe("edge query router", () => { 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/query/router.ts b/src/lib/edge/query/router.ts index 0a5789b4..cbc3565a 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -59,9 +59,11 @@ const PUBLIC_QUERY_PATHS = new Set([ "pages", "pages-dashboard", "referrers", + "retention", "performance", "countries", "filter-options", + "event-types", "page-hash", "page-query", "overview-page-path", @@ -92,6 +94,12 @@ const PUBLIC_QUERY_PATHS = new Set([ "referrer-dimension-trend", "client-dimension-trend", "client-cross-breakdown", + "utm-dimension-trend", + "utm-source", + "utm-medium", + "utm-campaign", + "utm-term", + "utm-content", ]); export async function routeQuery( From 146e2abcade35c67795f728768e62235a467de51 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:53:16 +0800 Subject: [PATCH 24/40] deps: removed unused dependents --- package-lock.json | 1324 +-------------------------------------------- package.json | 20 +- 2 files changed, 29 insertions(+), 1315 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b30152d..210906c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,13 @@ "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", @@ -28,7 +25,6 @@ "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 +49,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 +70,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 +1602,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 +1922,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 +1958,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 +3440,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 +3453,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 +3477,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 +3501,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 +3510,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 +3539,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 +3573,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 +3585,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,26 +3681,6 @@ "@math.gl/types": "4.1.0" } }, - "node_modules/@math.gl/culling": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", - "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", - "license": "MIT", - "dependencies": { - "@math.gl/core": "4.1.0", - "@math.gl/types": "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", @@ -4454,18 +4013,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", @@ -7492,80 +7039,24 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@tsconfig/node18": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-1.0.3.tgz", - "integrity": "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==", - "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", + "license": "BlueOak-1.0.0", "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" + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tsconfig/node18": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-1.0.3.tgz", + "integrity": "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==", + "dev": true, + "license": "MIT" + }, "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 +7068,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 +7091,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 +7207,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 +7737,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 +7920,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 +8235,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 +8331,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 +8364,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 +8584,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 +8888,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 +9019,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 +9115,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 +9401,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 +9616,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 +10157,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 +10623,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 +10681,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 +11350,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 +11470,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 +11582,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 +11591,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 +11742,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 +11837,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 +12216,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 +12277,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 +12535,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 +12560,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 +12574,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 +13022,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 +13049,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 +13178,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 +14160,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 +14172,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 +14227,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 +14283,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 +14452,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 +14493,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 +14871,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 +15210,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 +15237,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 +15415,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 +15852,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 +15960,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 +16071,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 +16126,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 +16341,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 +16364,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 +16485,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 +16598,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 +18653,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 +18847,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 +18855,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 2775b5c8..2d13e01f 100644 --- a/package.json +++ b/package.json @@ -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,16 +59,13 @@ }, "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", @@ -77,7 +75,6 @@ "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 +99,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 +120,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" From 1f7c57a917e9606ccaf441a71e470cd2ec29b06f Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:56:04 +0800 Subject: [PATCH 25/40] opti: optimize the script system --- scripts/build-tracker-sdk.ts | 15 +- scripts/check-env.ts | 4 +- ...contract.mjs => check-openapi-contract.ts} | 14 +- scripts/check-runner/cli.ts | 220 +++++++ scripts/check.ts | 12 + ...binding.mjs => ensure-ast-grep-binding.ts} | 39 +- scripts/generate-openapi.ts | 7 +- scripts/generate-skills.ts | 5 +- scripts/i18n-check/logger.ts | 17 +- scripts/prebuild.ts | 13 +- scripts/seed-local-mock.sql | 551 ------------------ scripts/shared/logger.ts | 27 + scripts/shared/paths.ts | 5 + 13 files changed, 321 insertions(+), 608 deletions(-) rename scripts/{check-openapi-contract.mjs => check-openapi-contract.ts} (97%) create mode 100644 scripts/check-runner/cli.ts create mode 100644 scripts/check.ts rename scripts/{ensure-ast-grep-binding.mjs => ensure-ast-grep-binding.ts} (63%) delete mode 100644 scripts/seed-local-mock.sql create mode 100644 scripts/shared/logger.ts create mode 100644 scripts/shared/paths.ts 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 97% rename from scripts/check-openapi-contract.mjs rename to scripts/check-openapi-contract.ts index be7f0d0b..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")); @@ -456,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 ad41f4df..b913e97d 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -5,7 +5,10 @@ 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")); @@ -3646,8 +3649,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 4dd233b4..7873fc35 100644 --- a/scripts/generate-skills.ts +++ b/scripts/generate-skills.ts @@ -4,8 +4,11 @@ import { execSync } from "child_process"; 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")); @@ -120,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"); From b5f759c48d1c7842f6a5f122b227898e59056d9b Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:31:10 +0800 Subject: [PATCH 26/40] perf: cache public api responses --- .../edge/__tests__/dashboard-cache.test.ts | 22 +++++++++- src/lib/edge/__tests__/query-entry.test.ts | 44 +++++++++++++++++++ .../__tests__/query-events-coverage.test.ts | 2 +- src/lib/edge/__tests__/query.test.ts | 7 ++- src/lib/edge/dashboard-cache.ts | 29 +++++++++--- src/lib/edge/query/core-types.ts | 2 +- src/lib/edge/query/entry.ts | 37 +++++++++++----- 7 files changed, 120 insertions(+), 23 deletions(-) diff --git a/src/lib/edge/__tests__/dashboard-cache.test.ts b/src/lib/edge/__tests__/dashboard-cache.test.ts index 88ed101b..7790d866 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() diff --git a/src/lib/edge/__tests__/query-entry.test.ts b/src/lib/edge/__tests__/query-entry.test.ts index 6199e6c6..2dae61ff 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, @@ -254,6 +264,11 @@ describe("edge query entry handlers", () => { ctx, url, expect.any(Function), + { + ttlSeconds: 300, + cacheName: "insightflare-public-query", + applyCacheHeadersOnBypass: true, + }, ); expect(routeQueryMock).toHaveBeenCalledWith( env, @@ -264,4 +279,33 @@ describe("edge query entry handlers", () => { edgeRequest, ); }); + + it("wraps public site metadata responses with public cache options", async () => { + const edgeRequest = request("/api/public-sites/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(); + }); }); 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.test.ts b/src/lib/edge/__tests__/query.test.ts index 0d0ea2bc..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, @@ -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/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/query/core-types.ts b/src/lib/edge/query/core-types.ts index d48b79dd..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 = { diff --git a/src/lib/edge/query/entry.ts b/src/lib/edge/query/entry.ts index 80b1eaff..0faf05d2 100644 --- a/src/lib/edge/query/entry.ts +++ b/src/lib/edge/query/entry.ts @@ -1,4 +1,7 @@ -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"; @@ -57,17 +60,27 @@ export async function handlePublicQuery( const segments = url.pathname.split("/").filter(Boolean); const pathname = segments.slice(3).join("/"); if (pathname === "site") { - return jsonResponse({ - ok: true, - data: { - slug: decodeURIComponent(segments[2] || ""), - name: site.name, - domain: site.domain, - id: site.id, - }, - }); + 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), + return withDashboardCache( + ctx, + url, + () => + routeQuery(env, site.id, pathname, url, { publicMode: true }, request), + PUBLIC_QUERY_CACHE_OPTIONS, ); } From ec5305d7be0dee74d22ffceaf19bdad744945606 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:07:01 +0800 Subject: [PATCH 27/40] fix: pre-release public sharing and api contracts --- .github/workflows/release.yml | 2 +- docs/openapi.json | 40 +++-- docs/openapi.yaml | 32 ++-- scripts/generate-openapi.ts | 33 ++++- .../api/__tests__/edge-query-routes.test.ts | 23 ++- src/app/api/public/[...segments]/route.ts | 12 ++ .../__tests__/client-request.test.ts | 57 ++++++++ src/lib/edge/__tests__/api-v1-docs.test.ts | 19 +++ src/lib/edge/__tests__/api-v1.test.ts | 138 ++++++++++++++++++ .../edge/__tests__/dashboard-cache.test.ts | 4 +- src/lib/edge/__tests__/query-core.test.ts | 61 ++++++++ src/lib/edge/__tests__/query-entry.test.ts | 24 ++- src/lib/edge/__tests__/query-router.test.ts | 68 ++++++++- src/schemas/__tests__/analytics.test.ts | 59 +++++--- src/schemas/analytics.ts | 45 +++--- 15 files changed, 533 insertions(+), 84 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f439ef11..fb3a2538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: npm run generate:openapi npm run generate:skills - node scripts/check-openapi-contract.mjs + npx tsx scripts/check-openapi-contract.ts git add package.json package-lock.json docs/openapi.json docs/openapi.yaml docs/skills.json diff --git a/docs/openapi.json b/docs/openapi.json index 51bc82d7..c380a114 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -958,10 +958,8 @@ "value": { "name": "Example Blog", "domain": "example.com", - "sharing": { - "publicEnabled": true, - "publicSlug": "example-blog" - } + "publicEnabled": true, + "publicSlug": "example-blog" } } } @@ -1114,10 +1112,7 @@ "summary": "Update a site", "value": { "name": "Example Blog", - "sharing": { - "publicEnabled": false, - "publicSlug": null - } + "publicEnabled": false } } } @@ -5871,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", @@ -5890,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.", diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a9c34664..67fc42b0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -666,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 @@ -744,9 +743,7 @@ paths: summary: Update a site value: name: Example Blog - sharing: - publicEnabled: false - publicSlug: null + publicEnabled: false responses: "200": description: Successful response @@ -3729,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 @@ -3743,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: diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index b913e97d..0cdeb877 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -887,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")), @@ -3237,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", }, }, }, @@ -3246,7 +3269,7 @@ function requestExamplesFor(schemaName: string | null) { summary: "Update a site", value: { name: "Example Blog", - sharing: { publicEnabled: false, publicSlug: null }, + publicEnabled: false, }, }, }, diff --git a/src/app/api/__tests__/edge-query-routes.test.ts b/src/app/api/__tests__/edge-query-routes.test.ts index e747bcbc..87803f8e 100644 --- a/src/app/api/__tests__/edge-query-routes.test.ts +++ b/src/app/api/__tests__/edge-query-routes.test.ts @@ -6,7 +6,12 @@ import { PATCH as privatePATCH, POST as privatePOST, } from "@/app/api/private/[...segments]/route"; -import { GET as publicGET } from "@/app/api/public/[...segments]/route"; +import { + DELETE as publicDELETE, + GET as publicGET, + PATCH as publicPATCH, + POST as publicPOST, +} 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"; @@ -122,6 +127,22 @@ describe("edge query route wrappers", () => { ); }); + it("routes public mutation methods to the public query handler for rejection", async () => { + const post = mockRuntime("/api/public/site/overview", "POST"); + await publicPOST(post); + + const patch = mockRuntime("/api/public/site/overview", "PATCH"); + await publicPATCH(patch); + + const del = mockRuntime("/api/public/site/overview", "DELETE"); + await publicDELETE(del); + + expect(handlePublicQueryMock).toHaveBeenCalledTimes(3); + expect( + handlePublicQueryMock.mock.calls.map((call) => call[0].method), + ).toEqual(["POST", "PATCH", "DELETE"]); + }); + it("routes DELETE requests to the query handler", async () => { const original = mockRuntime("/api/private/funnels?id=abc", "DELETE"); diff --git a/src/app/api/public/[...segments]/route.ts b/src/app/api/public/[...segments]/route.ts index 83ba293a..1811bffa 100644 --- a/src/app/api/public/[...segments]/route.ts +++ b/src/app/api/public/[...segments]/route.ts @@ -10,3 +10,15 @@ export async function GET(request: Request): Promise { } = await resolveEdgeRuntime(request); return handlePublicQuery(requestWithCf, env, url, 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/lib/dashboard/__tests__/client-request.test.ts b/src/lib/dashboard/__tests__/client-request.test.ts index e089de45..87cb9313 100644 --- a/src/lib/dashboard/__tests__/client-request.test.ts +++ b/src/lib/dashboard/__tests__/client-request.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchPrivateJson, fetchPrivateJsonMutate, + publicDashboardSiteId, } from "@/lib/dashboard/client-request"; import { handleDemoRequest } from "@/lib/realtime/mock"; @@ -109,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(); 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 f8f8487c..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 () => { diff --git a/src/lib/edge/__tests__/dashboard-cache.test.ts b/src/lib/edge/__tests__/dashboard-cache.test.ts index 7790d866..6a920ed3 100644 --- a/src/lib/edge/__tests__/dashboard-cache.test.ts +++ b/src/lib/edge/__tests__/dashboard-cache.test.ts @@ -107,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 @@ -126,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__/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 2dae61ff..a7f587bc 100644 --- a/src/lib/edge/__tests__/query-entry.test.ts +++ b/src/lib/edge/__tests__/query-entry.test.ts @@ -222,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", }); @@ -237,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, @@ -250,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; @@ -273,7 +274,7 @@ describe("edge query entry handlers", () => { expect(routeQueryMock).toHaveBeenCalledWith( env, "site-public", - "pages/top", + "pages", url, { publicMode: true }, edgeRequest, @@ -281,7 +282,7 @@ describe("edge query entry handlers", () => { }); it("wraps public site metadata responses with public cache options", async () => { - const edgeRequest = request("/api/public-sites/public/site"); + const edgeRequest = request("/api/public/public/site"); const url = new URL(edgeRequest.url); const ctx = {} as ExecutionContext; @@ -308,4 +309,15 @@ describe("edge query entry handlers", () => { ); 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-router.test.ts b/src/lib/edge/__tests__/query-router.test.ts index 7488422b..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,6 +118,72 @@ 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("sessions", true); 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(); From 9371a4f6c61b2e6acbc9ca9bb5b12a7c668dc463 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:51:27 +0800 Subject: [PATCH 28/40] refactor(api): add hono app shell and edge adapters --- package-lock.json | 1 + package.json | 1 + src/app/api/map-tiles/[z]/[x]/[y]/route.ts | 123 +---- src/app/collect/route.ts | 587 +------------------- src/lib/edge/admin-ws.ts | 177 ++++++ src/lib/edge/collect.ts | 596 +++++++++++++++++++++ src/lib/edge/legacy-admin.ts | 421 +++++++++++++++ src/lib/edge/legacy-archive.ts | 125 +++++ src/lib/edge/legacy-auth.ts | 199 +++++++ src/lib/edge/map-tiles.ts | 125 +++++ src/lib/hono/__tests__/app-routes.test.ts | 237 ++++++++ src/lib/hono/app.ts | 32 ++ src/lib/hono/routes/admin-ws.ts | 8 + src/lib/hono/routes/auth.ts | 12 + src/lib/hono/routes/collect.ts | 22 + src/lib/hono/routes/health.ts | 28 + src/lib/hono/routes/legacy-admin.ts | 26 + src/lib/hono/routes/legacy-archive.ts | 19 + src/lib/hono/routes/map-tiles.ts | 14 + src/lib/hono/routes/private/index.ts | 131 +++++ src/lib/hono/routes/public.ts | 75 +++ src/lib/hono/routes/tracker-script.ts | 10 + src/lib/hono/routes/v1/index.ts | 41 ++ src/lib/hono/routes/well-known.ts | 103 ++++ src/lib/hono/types.ts | 17 + workers/cf-worker.js | 184 +------ 26 files changed, 2442 insertions(+), 872 deletions(-) create mode 100644 src/lib/edge/admin-ws.ts create mode 100644 src/lib/edge/collect.ts create mode 100644 src/lib/edge/legacy-admin.ts create mode 100644 src/lib/edge/legacy-archive.ts create mode 100644 src/lib/edge/legacy-auth.ts create mode 100644 src/lib/edge/map-tiles.ts create mode 100644 src/lib/hono/__tests__/app-routes.test.ts create mode 100644 src/lib/hono/app.ts create mode 100644 src/lib/hono/routes/admin-ws.ts create mode 100644 src/lib/hono/routes/auth.ts create mode 100644 src/lib/hono/routes/collect.ts create mode 100644 src/lib/hono/routes/health.ts create mode 100644 src/lib/hono/routes/legacy-admin.ts create mode 100644 src/lib/hono/routes/legacy-archive.ts create mode 100644 src/lib/hono/routes/map-tiles.ts create mode 100644 src/lib/hono/routes/private/index.ts create mode 100644 src/lib/hono/routes/public.ts create mode 100644 src/lib/hono/routes/tracker-script.ts create mode 100644 src/lib/hono/routes/v1/index.ts create mode 100644 src/lib/hono/routes/well-known.ts create mode 100644 src/lib/hono/types.ts diff --git a/package-lock.json b/package-lock.json index 210906c3..dc3ff566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "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", diff --git a/package.json b/package.json index 2d13e01f..d5c778ca 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "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", 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/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/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/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/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..e40825b9 --- /dev/null +++ b/src/lib/edge/legacy-archive.ts @@ -0,0 +1,125 @@ +import { handlePrivateArchive } 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 handlePrivateArchive(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 handlePrivateArchive(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/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts new file mode 100644 index 00000000..f9a0ed83 --- /dev/null +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { handleAdminWs } from "@/lib/edge/admin-ws"; +import { handleApiV1 } from "@/lib/edge/api-v1"; +import { handlePrivateArchive } 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 { 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", () => ({ + handlePrivateArchive: 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/api-v1", () => ({ + handleApiV1: 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(handlePrivateArchive).mockResolvedValue(new Response("archive")); + vi.mocked(handlePrivateQuery).mockResolvedValue( + new Response("private-query"), + ); + vi.mocked(handlePublicQuery).mockResolvedValue( + new Response("public-query"), + ); + vi.mocked(handleApiV1).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(handlePrivateAdmin).toHaveBeenCalled(); + expect(handlePrivateArchive).toHaveBeenCalled(); + expect(handlePrivateQuery).toHaveBeenCalled(); + expect(handlePublicQuery).toHaveBeenCalled(); + expect(handleApiV1).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/app.ts b/src/lib/hono/app.ts new file mode 100644 index 00000000..9e3730e8 --- /dev/null +++ b/src/lib/hono/app.ts @@ -0,0 +1,32 @@ +import { Hono } from "hono"; + +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.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/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/index.ts b/src/lib/hono/routes/private/index.ts new file mode 100644 index 00000000..4366f96f --- /dev/null +++ b/src/lib/hono/routes/private/index.ts @@ -0,0 +1,131 @@ +import { Hono } from "hono"; + +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { handlePrivateArchive } from "@/lib/edge/archive-query"; +import { handlePrivateQuery } from "@/lib/edge/query"; +import type { AppEnv } from "@/lib/hono/types"; + +function urlFor(request: Request): URL { + return new URL(request.url); +} + +const dashboardQueryPaths = [ + "overview", + "trend", + "pages", + "referrers", + "pages-dashboard", + "page-hash", + "page-query", + "event-types", + "events-summary", + "events-trend", + "events-records", + "event-type-field-values", + "event-type-detail", + "event-record-detail", + "sessions", + "session-detail", + "visitor-detail", + "visitors", + "retention", + "performance", + "browser-trend", + "browser-engine-trend", + "browser-version-breakdown", + "browser-cross-breakdown", + "browser-radar", + "referrer-radar", + "referrer-dimension-trend", + "client-dimension-trend", + "utm-dimension-trend", + "client-cross-breakdown", + "utm-source", + "utm-medium", + "utm-campaign", + "utm-term", + "utm-content", + "countries", + "filter-options", + "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", + "funnels", + "team-dashboard", +] as const; + +const adminPaths = [ + "auth/login", + "auth/me", + "users", + "profile", + "teams", + "sites", + "members", + "site-config", + "script-snippet", + "api-keys", + "system-performance", + "scheduled-tasks", + "do-diagnostic", +] as const; + +export const privateRoutes = new Hono(); + +for (const path of adminPaths) { + privateRoutes.all(`/admin/${path}`, (c) => + handlePrivateAdmin(c.req.raw, c.env, urlFor(c.req.raw)), + ); +} + +privateRoutes.all("/admin/*", (c) => + handlePrivateAdmin(c.req.raw, c.env, urlFor(c.req.raw)), +); + +privateRoutes.all("/archive/manifest", (c) => + handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), +); + +privateRoutes.all("/archive/file", (c) => + handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), +); + +privateRoutes.all("/archive/*", (c) => + handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), +); + +for (const path of dashboardQueryPaths) { + privateRoutes.all(`/${path}`, (c) => + handlePrivateQuery( + c.req.raw, + c.env, + urlFor(c.req.raw), + c.executionCtx as unknown as ExecutionContext, + ), + ); +} + +privateRoutes.all("/*", (c) => + handlePrivateQuery( + c.req.raw, + c.env, + urlFor(c.req.raw), + c.executionCtx as unknown as ExecutionContext, + ), +); diff --git a/src/lib/hono/routes/public.ts b/src/lib/hono/routes/public.ts new file mode 100644 index 00000000..ca29d22e --- /dev/null +++ b/src/lib/hono/routes/public.ts @@ -0,0 +1,75 @@ +import { Hono } from "hono"; + +import { handlePublicQuery } from "@/lib/edge/query"; +import type { AppEnv } from "@/lib/hono/types"; + +const publicQueryPaths = [ + "site", + "overview", + "trend", + "pages", + "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; + +export const publicRoutes = new Hono(); + +for (const path of publicQueryPaths) { + publicRoutes.all(`/:slug/${path}`, (c) => + handlePublicQuery( + c.req.raw, + c.env, + new URL(c.req.raw.url), + c.executionCtx as unknown as ExecutionContext, + ), + ); +} + +publicRoutes.all("/:slug/*", (c) => + handlePublicQuery( + c.req.raw, + c.env, + new URL(c.req.raw.url), + c.executionCtx as unknown as ExecutionContext, + ), +); 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..54caabd8 --- /dev/null +++ b/src/lib/hono/routes/v1/index.ts @@ -0,0 +1,41 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; + +import { handleApiV1 } from "@/lib/edge/api-v1"; +import type { AppEnv } from "@/lib/hono/types"; + +function handleV1(c: Context) { + return handleApiV1( + c.req.raw, + c.env, + new URL(c.req.raw.url), + c.executionCtx as unknown as ExecutionContext, + ); +} + +export const v1Routes = new Hono(); + +v1Routes.all("/", handleV1); +v1Routes.all("/token", handleV1); +v1Routes.all("/token/check", handleV1); +v1Routes.all("/capabilities", handleV1); +v1Routes.all("/team", handleV1); +v1Routes.all("/team/*", handleV1); +v1Routes.all("/sites", handleV1); +v1Routes.all("/sites/:siteId", handleV1); +v1Routes.all("/sites/:siteId/tracking", handleV1); +v1Routes.all("/sites/:siteId/privacy", handleV1); +v1Routes.all("/sites/:siteId/sharing", handleV1); +v1Routes.all("/sites/:siteId/analytics/*", handleV1); +v1Routes.all("/sites/:siteId/events", handleV1); +v1Routes.all("/sites/:siteId/events/*", handleV1); +v1Routes.all("/sites/:siteId/visitors", handleV1); +v1Routes.all("/sites/:siteId/visitors/:visitorId", handleV1); +v1Routes.all("/sites/:siteId/sessions", handleV1); +v1Routes.all("/sites/:siteId/sessions/:sessionId", handleV1); +v1Routes.all("/sites/:siteId/funnels", handleV1); +v1Routes.all("/sites/:siteId/funnels/:funnelId", handleV1); +v1Routes.all("/sites/:siteId/performance", handleV1); +v1Routes.all("/sites/:siteId/realtime", handleV1); +v1Routes.all("/batch", handleV1); +v1Routes.all("/*", handleV1); 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..ffef292a --- /dev/null +++ b/src/lib/hono/types.ts @@ -0,0 +1,17 @@ +import type { EdgeSessionClaims } from "@/lib/edge/session-auth"; +import type { Env as EdgeEnv } from "@/lib/edge/types"; + +export type HonoBindings = EdgeEnv; + +export type HonoVariables = { + requestId: string; + session?: EdgeSessionClaims; + site?: { id: string; name?: string; domain?: string }; + publicSite?: { id: string; name?: string; domain?: string; slug?: string }; + apiPrincipal?: unknown; +}; + +export type AppEnv = { + Bindings: HonoBindings; + Variables: HonoVariables; +}; diff --git a/workers/cf-worker.js b/workers/cf-worker.js index 4f721fa9..8f9b9797 100644 --- a/workers/cf-worker.js +++ b/workers/cf-worker.js @@ -3,173 +3,7 @@ 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"; export class IngestDurableObject extends BaseIngestDurableObject {} @@ -177,12 +11,22 @@ function shouldSkipScheduledTasks(env) { return env.DISABLE_CRON_TASKS === "1" || env.NEXT_PUBLIC_DEMO_MODE === "1"; } +function shouldUseHono(pathname) { + return ( + pathname.startsWith("/api/") || + pathname === "/collect" || + pathname === "/script.js" || + pathname === "/healthz" || + pathname.startsWith("/.well-known/") || + pathname === "/admin/ws" + ); +} + 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); }, From e428022e2c75aa48461d3040254b47a271b57f96 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:55:55 +0800 Subject: [PATCH 29/40] refactor(api): share hono route inventory --- src/lib/edge/query/router.ts | 24 +++++++-- src/lib/hono/__tests__/path-match.test.ts | 34 ++++++++++++ src/lib/hono/path-match.ts | 10 ++++ src/lib/hono/routes/private/index.ts | 64 +---------------------- src/lib/hono/routes/public.ts | 50 +----------------- workers/cf-worker.js | 12 +---- 6 files changed, 70 insertions(+), 124 deletions(-) create mode 100644 src/lib/hono/__tests__/path-match.test.ts create mode 100644 src/lib/hono/path-match.ts diff --git a/src/lib/edge/query/router.ts b/src/lib/edge/query/router.ts index cbc3565a..c836d758 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -53,7 +53,7 @@ import { handleUtmDimensionTrend, } from "./technology"; -const PUBLIC_QUERY_PATHS = new Set([ +export const PUBLIC_QUERY_PATHS = [ "overview", "trend", "pages", @@ -100,7 +100,25 @@ const PUBLIC_QUERY_PATHS = new Set([ "utm-campaign", "utm-term", "utm-content", -]); +] as const; + +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 async function routeQuery( env: Env, @@ -129,7 +147,7 @@ export async function routeQuery( ctx, ); } - if (options.publicMode && !PUBLIC_QUERY_PATHS.has(pathname)) { + if (options.publicMode && !PUBLIC_QUERY_PATH_SET.has(pathname)) { return notFound(); } if (pathname === "funnels") { 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/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/private/index.ts b/src/lib/hono/routes/private/index.ts index 4366f96f..aee2e407 100644 --- a/src/lib/hono/routes/private/index.ts +++ b/src/lib/hono/routes/private/index.ts @@ -3,73 +3,13 @@ import { Hono } from "hono"; import { handlePrivateAdmin } from "@/lib/edge/admin"; import { handlePrivateArchive } from "@/lib/edge/archive-query"; import { handlePrivateQuery } from "@/lib/edge/query"; +import { DASHBOARD_QUERY_PATHS } from "@/lib/edge/query/router"; import type { AppEnv } from "@/lib/hono/types"; function urlFor(request: Request): URL { return new URL(request.url); } -const dashboardQueryPaths = [ - "overview", - "trend", - "pages", - "referrers", - "pages-dashboard", - "page-hash", - "page-query", - "event-types", - "events-summary", - "events-trend", - "events-records", - "event-type-field-values", - "event-type-detail", - "event-record-detail", - "sessions", - "session-detail", - "visitor-detail", - "visitors", - "retention", - "performance", - "browser-trend", - "browser-engine-trend", - "browser-version-breakdown", - "browser-cross-breakdown", - "browser-radar", - "referrer-radar", - "referrer-dimension-trend", - "client-dimension-trend", - "utm-dimension-trend", - "client-cross-breakdown", - "utm-source", - "utm-medium", - "utm-campaign", - "utm-term", - "utm-content", - "countries", - "filter-options", - "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", - "funnels", - "team-dashboard", -] as const; - const adminPaths = [ "auth/login", "auth/me", @@ -110,7 +50,7 @@ privateRoutes.all("/archive/*", (c) => handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), ); -for (const path of dashboardQueryPaths) { +for (const path of DASHBOARD_QUERY_PATHS) { privateRoutes.all(`/${path}`, (c) => handlePrivateQuery( c.req.raw, diff --git a/src/lib/hono/routes/public.ts b/src/lib/hono/routes/public.ts index ca29d22e..06344503 100644 --- a/src/lib/hono/routes/public.ts +++ b/src/lib/hono/routes/public.ts @@ -1,56 +1,10 @@ import { Hono } from "hono"; import { handlePublicQuery } from "@/lib/edge/query"; +import { PUBLIC_QUERY_PATHS } from "@/lib/edge/query/router"; import type { AppEnv } from "@/lib/hono/types"; -const publicQueryPaths = [ - "site", - "overview", - "trend", - "pages", - "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; +const publicQueryPaths = ["site", ...PUBLIC_QUERY_PATHS] as const; export const publicRoutes = new Hono(); diff --git a/workers/cf-worker.js b/workers/cf-worker.js index 8f9b9797..1c6e3264 100644 --- a/workers/cf-worker.js +++ b/workers/cf-worker.js @@ -4,6 +4,7 @@ import { IngestDurableObject as BaseIngestDurableObject } from "../src/lib/edge/ import { getScheduledTaskDefinition } from "../src/lib/edge/scheduled-task-registry"; import { runScheduledTask } from "../src/lib/edge/scheduled-task-runner"; import apiApp from "../src/lib/hono/app"; +import { shouldUseHono } from "../src/lib/hono/path-match"; export class IngestDurableObject extends BaseIngestDurableObject {} @@ -11,17 +12,6 @@ function shouldSkipScheduledTasks(env) { return env.DISABLE_CRON_TASKS === "1" || env.NEXT_PUBLIC_DEMO_MODE === "1"; } -function shouldUseHono(pathname) { - return ( - pathname.startsWith("/api/") || - pathname === "/collect" || - pathname === "/script.js" || - pathname === "/healthz" || - pathname.startsWith("/.well-known/") || - pathname === "/admin/ws" - ); -} - export default { async fetch(request, env, ctx) { const url = new URL(request.url); From 70b09f0f820087f61f57839a2c68d1b2644691a1 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:04:43 +0800 Subject: [PATCH 30/40] test(api): cover hono edge adapters --- src/lib/edge/__tests__/admin-ws.test.ts | 154 +++++++ .../__tests__/legacy-hono-adapters.test.ts | 390 ++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 src/lib/edge/__tests__/admin-ws.test.ts create mode 100644 src/lib/edge/__tests__/legacy-hono-adapters.test.ts 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__/legacy-hono-adapters.test.ts b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts new file mode 100644 index 00000000..af54f271 --- /dev/null +++ b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts @@ -0,0 +1,390 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { handlePrivateAdmin } from "@/lib/edge/admin"; +import { handleAuthLoginAdmin } from "@/lib/edge/admin-users"; +import { handlePrivateArchive } 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", () => ({ + handlePrivateArchive: 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(handlePrivateArchive).mockResolvedValue( + Response.json({ + ok: true, + files: [{ archiveKey: "archive/site/hour.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(handlePrivateArchive).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(handlePrivateArchive).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(handlePrivateArchive).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(handlePrivateArchive).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(handlePrivateArchive).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", + ); + }); +}); From 23d630bc1b926c44fb3cf12c406343a5d6a7a822 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:34:35 +0800 Subject: [PATCH 31/40] test(api): add Hono route parity baseline --- .gitignore | 2 +- .../fixtures/api-route-inventory.before.md | 51 ++++++++ src/lib/hono/__tests__/parity-helpers.test.ts | 68 +++++++++++ src/lib/hono/__tests__/parity-helpers.ts | 109 ++++++++++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/lib/hono/__tests__/fixtures/api-route-inventory.before.md create mode 100644 src/lib/hono/__tests__/parity-helpers.test.ts create mode 100644 src/lib/hono/__tests__/parity-helpers.ts 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/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__/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), + ); +} From b98fb73b694141756d5e59e481671575f38bffee Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:54:01 +0800 Subject: [PATCH 32/40] refactor(api): add Hono middleware foundation --- .../__tests__/middleware-foundation.test.ts | 521 ++++++++++++++++++ src/lib/hono/app.ts | 3 + src/lib/hono/middleware/api-key.ts | 50 ++ src/lib/hono/middleware/body.ts | 23 + src/lib/hono/middleware/dashboard-cache.ts | 26 + src/lib/hono/middleware/error-boundary.ts | 33 ++ src/lib/hono/middleware/method.ts | 32 ++ src/lib/hono/middleware/request-id.ts | 12 + src/lib/hono/middleware/same-origin.ts | 15 + src/lib/hono/middleware/session.ts | 18 + src/lib/hono/middleware/site.ts | 87 +++ src/lib/hono/types.ts | 30 +- src/lib/hono/utils/context.ts | 15 + src/lib/hono/utils/request.ts | 26 + src/lib/hono/utils/response.ts | 14 + 15 files changed, 902 insertions(+), 3 deletions(-) create mode 100644 src/lib/hono/__tests__/middleware-foundation.test.ts create mode 100644 src/lib/hono/middleware/api-key.ts create mode 100644 src/lib/hono/middleware/body.ts create mode 100644 src/lib/hono/middleware/dashboard-cache.ts create mode 100644 src/lib/hono/middleware/error-boundary.ts create mode 100644 src/lib/hono/middleware/method.ts create mode 100644 src/lib/hono/middleware/request-id.ts create mode 100644 src/lib/hono/middleware/same-origin.ts create mode 100644 src/lib/hono/middleware/session.ts create mode 100644 src/lib/hono/middleware/site.ts create mode 100644 src/lib/hono/utils/context.ts create mode 100644 src/lib/hono/utils/request.ts create mode 100644 src/lib/hono/utils/response.ts 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/app.ts b/src/lib/hono/app.ts index 9e3730e8..454ffd9a 100644 --- a/src/lib/hono/app.ts +++ b/src/lib/hono/app.ts @@ -1,5 +1,6 @@ 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"; @@ -16,6 +17,8 @@ import type { AppEnv } from "./types"; export const apiApp = new Hono(); +apiApp.onError(handleHonoError); + apiApp.route("/", healthRoutes); apiApp.route("/", wellKnownRoutes); apiApp.route("/", collectRoutes); 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/types.ts b/src/lib/hono/types.ts index ffef292a..8c34528e 100644 --- a/src/lib/hono/types.ts +++ b/src/lib/hono/types.ts @@ -1,14 +1,38 @@ +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; - site?: { id: string; name?: string; domain?: string }; - publicSite?: { id: string; name?: string; domain?: string; slug?: string }; - apiPrincipal?: unknown; + privateSite?: HonoSite; + site?: HonoSite; + publicSite?: HonoPublicSite; + apiPrincipal?: ApiKeyPrincipal; + apiSite?: HonoApiSite; }; export type AppEnv = { 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", + ); +} From fb4ec7e8b70010db0fc1926276f40de10839ebc2 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:02:53 +0800 Subject: [PATCH 33/40] refactor(api): route private dashboard API through Hono --- src/lib/hono/__tests__/app-routes.test.ts | 37 +++- .../__tests__/private-query-routes.test.ts | 202 ++++++++++++++++++ src/lib/hono/routes/private/index.ts | 24 +-- src/lib/hono/routes/private/query.ts | 73 +++++++ 4 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 src/lib/hono/__tests__/private-query-routes.test.ts create mode 100644 src/lib/hono/routes/private/query.ts diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index f9a0ed83..d64d6b9e 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -13,6 +13,10 @@ 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 { resolvePrivateSite } from "@/lib/edge/query/core"; +import type * as QueryRouterModule from "@/lib/edge/query/router"; +import { routeQuery } from "@/lib/edge/query/router"; import { handleTrackerScriptRequest } from "@/lib/edge/script-endpoint"; import apiApp from "@/lib/hono/app"; @@ -56,6 +60,22 @@ vi.mock("@/lib/edge/query", () => ({ handlePublicQuery: vi.fn(), })); +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, + routeQuery: vi.fn(), + }; +}); + vi.mock("@/lib/edge/api-v1", () => ({ handleApiV1: vi.fn(), })); @@ -89,6 +109,12 @@ describe("Hono API app routing", () => { vi.mocked(handlePrivateQuery).mockResolvedValue( new Response("private-query"), ); + vi.mocked(resolvePrivateSite).mockResolvedValue({ + id: "site-1", + name: "Site", + domain: "app.test", + }); + vi.mocked(routeQuery).mockResolvedValue(new Response("private-query")); vi.mocked(handlePublicQuery).mockResolvedValue( new Response("public-query"), ); @@ -198,7 +224,16 @@ describe("Hono API app routing", () => { expect(handlePrivateAdmin).toHaveBeenCalled(); expect(handlePrivateArchive).toHaveBeenCalled(); - expect(handlePrivateQuery).toHaveBeenCalled(); + expect(resolvePrivateSite).toHaveBeenCalled(); + expect(routeQuery).toHaveBeenCalledWith( + env, + "site-1", + "overview", + new URL("https://app.test/api/private/overview"), + { publicMode: false }, + expect.any(Request), + ); + expect(handlePrivateQuery).not.toHaveBeenCalled(); expect(handlePublicQuery).toHaveBeenCalled(); expect(handleApiV1).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..4d2150d8 --- /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 { routeQuery } 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, + routeQuery: 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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).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/routes/private/index.ts b/src/lib/hono/routes/private/index.ts index aee2e407..b0c2b085 100644 --- a/src/lib/hono/routes/private/index.ts +++ b/src/lib/hono/routes/private/index.ts @@ -2,10 +2,10 @@ import { Hono } from "hono"; import { handlePrivateAdmin } from "@/lib/edge/admin"; import { handlePrivateArchive } from "@/lib/edge/archive-query"; -import { handlePrivateQuery } from "@/lib/edge/query"; -import { DASHBOARD_QUERY_PATHS } from "@/lib/edge/query/router"; import type { AppEnv } from "@/lib/hono/types"; +import { privateQueryRoutes } from "./query"; + function urlFor(request: Request): URL { return new URL(request.url); } @@ -50,22 +50,4 @@ privateRoutes.all("/archive/*", (c) => handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), ); -for (const path of DASHBOARD_QUERY_PATHS) { - privateRoutes.all(`/${path}`, (c) => - handlePrivateQuery( - c.req.raw, - c.env, - urlFor(c.req.raw), - c.executionCtx as unknown as ExecutionContext, - ), - ); -} - -privateRoutes.all("/*", (c) => - handlePrivateQuery( - c.req.raw, - c.env, - urlFor(c.req.raw), - c.executionCtx as unknown as ExecutionContext, - ), -); +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..1fea5271 --- /dev/null +++ b/src/lib/hono/routes/private/query.ts @@ -0,0 +1,73 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; + +import { notAllowed } from "@/lib/edge/query/core"; +import { DASHBOARD_QUERY_PATHS, routeQuery } 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 routeQuery( + 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); + }, +); From 609d2b8bbd2897cb7387a36a94ff732dcc35101c Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:09:17 +0800 Subject: [PATCH 34/40] refactor(api): route public share API through Hono --- src/lib/hono/__tests__/app-routes.test.ts | 11 +- .../__tests__/public-query-routes.test.ts | 184 ++++++++++++++++++ src/lib/hono/routes/public.ts | 29 --- src/lib/hono/routes/public/index.ts | 9 + src/lib/hono/routes/public/query.ts | 78 ++++++++ 5 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 src/lib/hono/__tests__/public-query-routes.test.ts delete mode 100644 src/lib/hono/routes/public.ts create mode 100644 src/lib/hono/routes/public/index.ts create mode 100644 src/lib/hono/routes/public/query.ts diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index d64d6b9e..0020cb36 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -14,7 +14,7 @@ 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 { resolvePrivateSite } from "@/lib/edge/query/core"; +import { fetchPublicSite, resolvePrivateSite } from "@/lib/edge/query/core"; import type * as QueryRouterModule from "@/lib/edge/query/router"; import { routeQuery } from "@/lib/edge/query/router"; import { handleTrackerScriptRequest } from "@/lib/edge/script-endpoint"; @@ -64,6 +64,7 @@ vi.mock("@/lib/edge/query/core", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + fetchPublicSite: vi.fn(), resolvePrivateSite: vi.fn(), }; }); @@ -114,6 +115,11 @@ describe("Hono API app routing", () => { name: "Site", domain: "app.test", }); + vi.mocked(fetchPublicSite).mockResolvedValue({ + id: "public-site", + name: "Public Site", + domain: "public.test", + }); vi.mocked(routeQuery).mockResolvedValue(new Response("private-query")); vi.mocked(handlePublicQuery).mockResolvedValue( new Response("public-query"), @@ -234,7 +240,8 @@ describe("Hono API app routing", () => { expect.any(Request), ); expect(handlePrivateQuery).not.toHaveBeenCalled(); - expect(handlePublicQuery).toHaveBeenCalled(); + expect(fetchPublicSite).toHaveBeenCalled(); + expect(handlePublicQuery).not.toHaveBeenCalled(); expect(handleApiV1).toHaveBeenCalled(); }); 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..1a969076 --- /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 { routeQuery } 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, + routeQuery: 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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).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(routeQuery).not.toHaveBeenCalled(); + }); + + it("keeps private-only endpoints behind the public query allowlist", async () => { + vi.mocked(routeQuery).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(routeQuery).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/routes/public.ts b/src/lib/hono/routes/public.ts deleted file mode 100644 index 06344503..00000000 --- a/src/lib/hono/routes/public.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Hono } from "hono"; - -import { handlePublicQuery } from "@/lib/edge/query"; -import { PUBLIC_QUERY_PATHS } from "@/lib/edge/query/router"; -import type { AppEnv } from "@/lib/hono/types"; - -const publicQueryPaths = ["site", ...PUBLIC_QUERY_PATHS] as const; - -export const publicRoutes = new Hono(); - -for (const path of publicQueryPaths) { - publicRoutes.all(`/:slug/${path}`, (c) => - handlePublicQuery( - c.req.raw, - c.env, - new URL(c.req.raw.url), - c.executionCtx as unknown as ExecutionContext, - ), - ); -} - -publicRoutes.all("/:slug/*", (c) => - handlePublicQuery( - c.req.raw, - c.env, - new URL(c.req.raw.url), - c.executionCtx as unknown as ExecutionContext, - ), -); 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..1b0dfcaa --- /dev/null +++ b/src/lib/hono/routes/public/query.ts @@ -0,0 +1,78 @@ +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 { PUBLIC_QUERY_PATHS, routeQuery } 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 routeQuery( + 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); + }, +); From e69686d460e20ce169ee48435aee90b823866de7 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:16:32 +0800 Subject: [PATCH 35/40] refactor(api): route private admin API through Hono --- src/lib/hono/__tests__/app-routes.test.ts | 12 +- .../__tests__/private-admin-routes.test.ts | 142 ++++++++++++++++++ src/lib/hono/routes/private/admin.ts | 59 ++++++++ src/lib/hono/routes/private/index.ts | 28 +--- 4 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 src/lib/hono/__tests__/private-admin-routes.test.ts create mode 100644 src/lib/hono/routes/private/admin.ts diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index 0020cb36..9f653b27 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -1,6 +1,7 @@ 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 { handleApiV1 } from "@/lib/edge/api-v1"; import { handlePrivateArchive } from "@/lib/edge/archive-query"; @@ -32,6 +33,13 @@ vi.mock("@/lib/edge/archive-query", () => ({ handlePrivateArchive: 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(), @@ -106,6 +114,7 @@ describe("Hono API app routing", () => { 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(handlePrivateQuery).mockResolvedValue( new Response("private-query"), @@ -228,7 +237,8 @@ describe("Hono API app routing", () => { executionCtx, ); - expect(handlePrivateAdmin).toHaveBeenCalled(); + expect(handleUsersAdmin).toHaveBeenCalled(); + expect(handlePrivateAdmin).not.toHaveBeenCalled(); expect(handlePrivateArchive).toHaveBeenCalled(); expect(resolvePrivateSite).toHaveBeenCalled(); expect(routeQuery).toHaveBeenCalledWith( 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/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/index.ts b/src/lib/hono/routes/private/index.ts index b0c2b085..95430276 100644 --- a/src/lib/hono/routes/private/index.ts +++ b/src/lib/hono/routes/private/index.ts @@ -1,42 +1,18 @@ import { Hono } from "hono"; -import { handlePrivateAdmin } from "@/lib/edge/admin"; import { handlePrivateArchive } from "@/lib/edge/archive-query"; import type { AppEnv } from "@/lib/hono/types"; +import { privateAdminRoutes } from "./admin"; import { privateQueryRoutes } from "./query"; function urlFor(request: Request): URL { return new URL(request.url); } -const adminPaths = [ - "auth/login", - "auth/me", - "users", - "profile", - "teams", - "sites", - "members", - "site-config", - "script-snippet", - "api-keys", - "system-performance", - "scheduled-tasks", - "do-diagnostic", -] as const; - export const privateRoutes = new Hono(); -for (const path of adminPaths) { - privateRoutes.all(`/admin/${path}`, (c) => - handlePrivateAdmin(c.req.raw, c.env, urlFor(c.req.raw)), - ); -} - -privateRoutes.all("/admin/*", (c) => - handlePrivateAdmin(c.req.raw, c.env, urlFor(c.req.raw)), -); +privateRoutes.route("/admin", privateAdminRoutes); privateRoutes.all("/archive/manifest", (c) => handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), From 6108866201f37be9d809f7e45c90369fb8be9f4a Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:25:35 +0800 Subject: [PATCH 36/40] refactor(api): route archive APIs through Hono --- .../__tests__/legacy-hono-adapters.test.ts | 23 +++-- src/lib/edge/archive-query.ts | 8 +- src/lib/edge/legacy-archive.ts | 17 +++- src/lib/hono/__tests__/app-routes.test.ts | 13 ++- .../__tests__/private-archive-routes.test.ts | 90 +++++++++++++++++++ src/lib/hono/routes/private/archive.ts | 19 ++++ src/lib/hono/routes/private/index.ts | 20 +---- 7 files changed, 155 insertions(+), 35 deletions(-) create mode 100644 src/lib/hono/__tests__/private-archive-routes.test.ts create mode 100644 src/lib/hono/routes/private/archive.ts diff --git a/src/lib/edge/__tests__/legacy-hono-adapters.test.ts b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts index af54f271..a11ed3b8 100644 --- a/src/lib/edge/__tests__/legacy-hono-adapters.test.ts +++ b/src/lib/edge/__tests__/legacy-hono-adapters.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { handlePrivateAdmin } from "@/lib/edge/admin"; import { handleAuthLoginAdmin } from "@/lib/edge/admin-users"; -import { handlePrivateArchive } from "@/lib/edge/archive-query"; +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; import { handleLegacyAdminMember, handleLegacyAdminProfile, @@ -25,7 +28,8 @@ vi.mock("@/lib/edge/admin", () => ({ })); vi.mock("@/lib/edge/archive-query", () => ({ - handlePrivateArchive: vi.fn(), + handlePrivateArchiveFile: vi.fn(), + handlePrivateArchiveManifest: vi.fn(), })); vi.mock("@/lib/edge/admin-users", () => ({ @@ -65,12 +69,15 @@ describe("legacy Hono edge adapters", () => { }, }); }); - vi.mocked(handlePrivateArchive).mockResolvedValue( + 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, @@ -308,7 +315,7 @@ describe("legacy Hono edge adapters", () => { (manifestBody.files as Array<{ fetchUrl: string }>)[0].fetchUrl, ).toBe("/api/archive/file?key=archive%2Fsite%2Fhour.parquet"); - vi.mocked(handlePrivateArchive).mockResolvedValueOnce( + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( new Response("parquet", { status: 206, headers: { @@ -337,7 +344,7 @@ describe("legacy Hono edge adapters", () => { ); expect(missingManifestSite.status).toBe(400); - vi.mocked(handlePrivateArchive).mockResolvedValueOnce( + vi.mocked(handlePrivateArchiveManifest).mockResolvedValueOnce( new Response("nope", { status: 403 }), ); const manifestDenied = await handleLegacyArchiveManifest( @@ -346,7 +353,7 @@ describe("legacy Hono edge adapters", () => { ); expect(manifestDenied.status).toBe(403); - vi.mocked(handlePrivateArchive).mockResolvedValueOnce( + vi.mocked(handlePrivateArchiveManifest).mockResolvedValueOnce( new Response("not json", { status: 200 }), ); const invalidManifest = await handleLegacyArchiveManifest( @@ -361,7 +368,7 @@ describe("legacy Hono edge adapters", () => { ); expect(missingFileKey.status).toBe(400); - vi.mocked(handlePrivateArchive).mockResolvedValueOnce( + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( new Response("missing", { status: 404 }), ); const fileMissing = await handleLegacyArchiveFile( @@ -370,7 +377,7 @@ describe("legacy Hono edge adapters", () => { ); expect(fileMissing.status).toBe(404); - vi.mocked(handlePrivateArchive).mockResolvedValueOnce( + vi.mocked(handlePrivateArchiveFile).mockResolvedValueOnce( new Response(new Uint8Array([1, 2, 3, 4]), { headers: { "content-length": "4" }, }), diff --git a/src/lib/edge/archive-query.ts b/src/lib/edge/archive-query.ts index bc50f86f..46744e62 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, @@ -254,10 +254,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/legacy-archive.ts b/src/lib/edge/legacy-archive.ts index e40825b9..371fb6a1 100644 --- a/src/lib/edge/legacy-archive.ts +++ b/src/lib/edge/legacy-archive.ts @@ -1,4 +1,7 @@ -import { handlePrivateArchive } from "@/lib/edge/archive-query"; +import { + handlePrivateArchiveFile, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; import type { Env } from "@/lib/edge/types"; import { bad, errorResponse, jsonResponseFor } from "@/lib/response"; @@ -23,7 +26,11 @@ export async function handleLegacyArchiveManifest( method: "GET", headers: request.headers, }); - const edgeRes = await handlePrivateArchive(privateRequest, env, privateUrl); + const edgeRes = await handlePrivateArchiveManifest( + privateRequest, + env, + privateUrl, + ); const text = await edgeRes.text(); if (!edgeRes.ok) { @@ -86,7 +93,11 @@ export async function handleLegacyArchiveFile( method: request.method === "HEAD" ? "HEAD" : "GET", headers, }); - const edgeRes = await handlePrivateArchive(privateRequest, env, privateUrl); + const edgeRes = await handlePrivateArchiveFile( + privateRequest, + env, + privateUrl, + ); if (!edgeRes.ok && edgeRes.status !== 206) { const text = await edgeRes.text(); return errorResponse( diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index 9f653b27..451ca7ee 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -4,7 +4,10 @@ import { handlePrivateAdmin } from "@/lib/edge/admin"; import { handleUsersAdmin } from "@/lib/edge/admin-users"; import { handleAdminWs } from "@/lib/edge/admin-ws"; import { handleApiV1 } from "@/lib/edge/api-v1"; -import { handlePrivateArchive } from "@/lib/edge/archive-query"; +import { + handlePrivateArchive, + handlePrivateArchiveManifest, +} from "@/lib/edge/archive-query"; import { handleCollectOptionsRequest, handleCollectRequest, @@ -30,7 +33,9 @@ vi.mock("@/lib/edge/admin-ws", () => ({ })); vi.mock("@/lib/edge/archive-query", () => ({ + handlePrivateArchiveFile: vi.fn(), handlePrivateArchive: vi.fn(), + handlePrivateArchiveManifest: vi.fn(), })); vi.mock("@/lib/edge/admin-users", () => ({ @@ -116,6 +121,9 @@ describe("Hono API app routing", () => { 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"), ); @@ -239,7 +247,8 @@ describe("Hono API app routing", () => { expect(handleUsersAdmin).toHaveBeenCalled(); expect(handlePrivateAdmin).not.toHaveBeenCalled(); - expect(handlePrivateArchive).toHaveBeenCalled(); + expect(handlePrivateArchiveManifest).toHaveBeenCalled(); + expect(handlePrivateArchive).not.toHaveBeenCalled(); expect(resolvePrivateSite).toHaveBeenCalled(); expect(routeQuery).toHaveBeenCalledWith( env, 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/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 index 95430276..18efd42d 100644 --- a/src/lib/hono/routes/private/index.ts +++ b/src/lib/hono/routes/private/index.ts @@ -1,29 +1,13 @@ import { Hono } from "hono"; -import { handlePrivateArchive } from "@/lib/edge/archive-query"; import type { AppEnv } from "@/lib/hono/types"; import { privateAdminRoutes } from "./admin"; +import { privateArchiveRoutes } from "./archive"; import { privateQueryRoutes } from "./query"; -function urlFor(request: Request): URL { - return new URL(request.url); -} - export const privateRoutes = new Hono(); privateRoutes.route("/admin", privateAdminRoutes); - -privateRoutes.all("/archive/manifest", (c) => - handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), -); - -privateRoutes.all("/archive/file", (c) => - handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), -); - -privateRoutes.all("/archive/*", (c) => - handlePrivateArchive(c.req.raw, c.env, urlFor(c.req.raw)), -); - +privateRoutes.route("/archive", privateArchiveRoutes); privateRoutes.route("/", privateQueryRoutes); From 5c232eed089bd7a54a91512a8bd413be5d52ece0 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:40:09 +0800 Subject: [PATCH 37/40] refactor(api): route API v1 resources through Hono --- src/lib/edge/api-v1.ts | 49 ++-- src/lib/hono/__tests__/app-routes.test.ts | 41 ++- src/lib/hono/__tests__/v1-routes.test.ts | 236 +++++++++++++++ src/lib/hono/routes/v1/index.ts | 336 ++++++++++++++++++++-- 4 files changed, 609 insertions(+), 53 deletions(-) create mode 100644 src/lib/hono/__tests__/v1-routes.test.ts diff --git a/src/lib/edge/api-v1.ts b/src/lib/edge/api-v1.ts index d0925c21..0ccb3e32 100644 --- a/src/lib/edge/api-v1.ts +++ b/src/lib/edge/api-v1.ts @@ -180,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) => { @@ -1212,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( { @@ -1232,7 +1232,7 @@ async function handleRoot(request: Request): Promise { ); } -async function handleToken( +export async function handleToken( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1261,7 +1261,7 @@ async function handleToken( ); } -async function handleTokenCheck( +export async function handleTokenCheck( request: Request, principal: ApiKeyPrincipal, ): Promise { @@ -1302,7 +1302,7 @@ async function handleTokenCheck( ); } -async function handleCapabilities( +export async function handleCapabilities( request: Request, principal: ApiKeyPrincipal, ): Promise { @@ -1343,7 +1343,7 @@ async function handleCapabilities( ); } -async function handleTeam( +export async function handleTeam( request: Request, env: Env, url: URL, @@ -1542,7 +1542,7 @@ async function handleTeamAnalytics( ); } -async function handleSitesCollection( +export async function handleSitesCollection( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1604,7 +1604,7 @@ async function handleSitesCollection( return methodNotAllowed(request); } -async function handleSiteResource( +export async function handleSiteResource( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1681,7 +1681,7 @@ async function handleSiteResource( return methodNotAllowed(request); } -async function handleTracking( +export async function handleTracking( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1717,7 +1717,7 @@ async function handleTracking( return methodNotAllowed(request); } -async function handlePrivacy( +export async function handlePrivacy( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1762,7 +1762,7 @@ async function handlePrivacy( return methodNotAllowed(request); } -async function handleSharing( +export async function handleSharing( request: Request, env: Env, principal: ApiKeyPrincipal, @@ -1819,7 +1819,7 @@ async function handleSharing( return methodNotAllowed(request); } -async function handleTrackingScript( +export async function handleTrackingScript( request: Request, env: Env, url: URL, @@ -1885,7 +1885,7 @@ function analyticsSchema(siteId: string) { }; } -async function handleAnalytics( +export async function handleAnalytics( request: Request, env: Env, url: URL, @@ -2057,7 +2057,7 @@ async function handleAnalytics( ); } -async function handleEvents( +export async function handleEvents( request: Request, env: Env, url: URL, @@ -2148,7 +2148,7 @@ async function handleEvents( ); } -async function handleJourneys( +export async function handleJourneys( request: Request, env: Env, url: URL, @@ -2393,7 +2393,7 @@ async function handleFunnelResource( return methodNotAllowed(request); } -async function handleFunnels( +export async function handleFunnels( request: Request, env: Env, url: URL, @@ -2470,7 +2470,7 @@ async function handleFunnels( ); } -async function handlePerformance( +export async function handlePerformance( request: Request, env: Env, url: URL, @@ -2537,7 +2537,7 @@ async function handlePerformance( ); } -async function handleRealtime( +export async function handleRealtime( request: Request, env: Env, url: URL, @@ -2580,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); @@ -2636,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, @@ -2661,7 +2668,7 @@ export async function handleApiV1( 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); diff --git a/src/lib/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index 451ca7ee..7460e380 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -3,7 +3,8 @@ 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 { handleApiV1 } from "@/lib/edge/api-v1"; +import { authenticateApiKey } from "@/lib/edge/api-key-auth"; +import { handleApiV1, handleCapabilities } from "@/lib/edge/api-v1"; import { handlePrivateArchive, handlePrivateArchiveManifest, @@ -91,7 +92,34 @@ vi.mock("@/lib/edge/query/router", async (importOriginal) => { }); 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", () => ({ @@ -142,6 +170,14 @@ describe("Hono API app routing", () => { 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"), ); @@ -261,7 +297,8 @@ describe("Hono API app routing", () => { expect(handlePrivateQuery).not.toHaveBeenCalled(); expect(fetchPublicSite).toHaveBeenCalled(); expect(handlePublicQuery).not.toHaveBeenCalled(); - expect(handleApiV1).toHaveBeenCalled(); + expect(handleCapabilities).toHaveBeenCalled(); + expect(handleApiV1).not.toHaveBeenCalled(); }); it("routes legacy and map endpoints through Hono", async () => { 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/routes/v1/index.ts b/src/lib/hono/routes/v1/index.ts index 54caabd8..9557e539 100644 --- a/src/lib/hono/routes/v1/index.ts +++ b/src/lib/hono/routes/v1/index.ts @@ -1,41 +1,317 @@ import type { Context } from "hono"; import { Hono } from "hono"; -import { handleApiV1 } from "@/lib/edge/api-v1"; +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 handleV1(c: Context) { - return handleApiV1( +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, - c.env, - new URL(c.req.raw.url), - c.executionCtx as unknown as ExecutionContext, ); } +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.all("/", handleV1); -v1Routes.all("/token", handleV1); -v1Routes.all("/token/check", handleV1); -v1Routes.all("/capabilities", handleV1); -v1Routes.all("/team", handleV1); -v1Routes.all("/team/*", handleV1); -v1Routes.all("/sites", handleV1); -v1Routes.all("/sites/:siteId", handleV1); -v1Routes.all("/sites/:siteId/tracking", handleV1); -v1Routes.all("/sites/:siteId/privacy", handleV1); -v1Routes.all("/sites/:siteId/sharing", handleV1); -v1Routes.all("/sites/:siteId/analytics/*", handleV1); -v1Routes.all("/sites/:siteId/events", handleV1); -v1Routes.all("/sites/:siteId/events/*", handleV1); -v1Routes.all("/sites/:siteId/visitors", handleV1); -v1Routes.all("/sites/:siteId/visitors/:visitorId", handleV1); -v1Routes.all("/sites/:siteId/sessions", handleV1); -v1Routes.all("/sites/:siteId/sessions/:sessionId", handleV1); -v1Routes.all("/sites/:siteId/funnels", handleV1); -v1Routes.all("/sites/:siteId/funnels/:funnelId", handleV1); -v1Routes.all("/sites/:siteId/performance", handleV1); -v1Routes.all("/sites/:siteId/realtime", handleV1); -v1Routes.all("/batch", handleV1); -v1Routes.all("/*", handleV1); +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); From b739f9165d7ae719cf61c8bc186d44e73f5f0d32 Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:46:51 +0800 Subject: [PATCH 38/40] chore(api): align Next API fallbacks with Hono --- .../api/__tests__/edge-query-routes.test.ts | 170 ++++-------------- src/app/api/private/[...segments]/route.ts | 53 ++---- src/app/api/public/[...segments]/route.ts | 5 +- src/app/api/v1/[[...path]]/route.ts | 5 +- src/app/api/v1/__tests__/route.test.ts | 162 +++++------------ 5 files changed, 87 insertions(+), 308 deletions(-) diff --git a/src/app/api/__tests__/edge-query-routes.test.ts b/src/app/api/__tests__/edge-query-routes.test.ts index 87803f8e..db02352b 100644 --- a/src/app/api/__tests__/edge-query-routes.test.ts +++ b/src/app/api/__tests__/edge-query-routes.test.ts @@ -12,32 +12,20 @@ import { PATCH as publicPATCH, POST as publicPOST, } 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 { 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: {} }; @@ -60,126 +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, - ); + apiFetchMock.mockResolvedValue(new Response("hono")); }); - 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 public mutation methods to the public query handler for rejection", async () => { - const post = mockRuntime("/api/public/site/overview", "POST"); - await publicPOST(post); - - const patch = mockRuntime("/api/public/site/overview", "PATCH"); - await publicPATCH(patch); - - const del = mockRuntime("/api/public/site/overview", "DELETE"); - await publicDELETE(del); - - expect(handlePublicQueryMock).toHaveBeenCalledTimes(3); - expect( - handlePublicQueryMock.mock.calls.map((call) => call[0].method), - ).toEqual(["POST", "PATCH", "DELETE"]); - }); - - 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, - ); - }); - - 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/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 1811bffa..5fd7e308 100644 --- a/src/app/api/public/[...segments]/route.ts +++ b/src/app/api/public/[...segments]/route.ts @@ -1,14 +1,13 @@ -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 { 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); + }, + ); }); From 06816a14f459010c7547c7f7ee7f6a5321e6c5cc Mon Sep 17 00:00:00 2001 From: RavelloH <68409330+RavelloH@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:55:01 +0800 Subject: [PATCH 39/40] chore(api): mark legacy routers as compatibility wrappers --- src/lib/edge/admin.ts | 3 + src/lib/edge/api-v1.ts | 3 + src/lib/edge/archive-query.ts | 3 + src/lib/edge/query/entry.ts | 6 + src/lib/edge/query/router.ts | 387 +++++++++--------- src/lib/hono/__tests__/app-routes.test.ts | 10 +- .../__tests__/private-query-routes.test.ts | 18 +- .../__tests__/public-query-routes.test.ts | 18 +- src/lib/hono/routes/private/query.ts | 7 +- src/lib/hono/routes/public/query.ts | 7 +- 10 files changed, 243 insertions(+), 219 deletions(-) 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.ts b/src/lib/edge/api-v1.ts index 0ccb3e32..e8a12324 100644 --- a/src/lib/edge/api-v1.ts +++ b/src/lib/edge/api-v1.ts @@ -2662,6 +2662,9 @@ export async function handleBatch( ); } +/** + * Compatibility wrapper. Production routing lives in src/lib/hono/routes. + */ export async function handleApiV1( request: Request, env: Env, diff --git a/src/lib/edge/archive-query.ts b/src/lib/edge/archive-query.ts index 46744e62..b2d5c97c 100644 --- a/src/lib/edge/archive-query.ts +++ b/src/lib/edge/archive-query.ts @@ -247,6 +247,9 @@ export async function handlePrivateArchiveFile( 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, diff --git a/src/lib/edge/query/entry.ts b/src/lib/edge/query/entry.ts index 0faf05d2..1cd61fb2 100644 --- a/src/lib/edge/query/entry.ts +++ b/src/lib/edge/query/entry.ts @@ -9,6 +9,9 @@ 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, @@ -48,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, diff --git a/src/lib/edge/query/router.ts b/src/lib/edge/query/router.ts index c836d758..941e1c9b 100644 --- a/src/lib/edge/query/router.ts +++ b/src/lib/edge/query/router.ts @@ -120,234 +120,235 @@ export const DASHBOARD_QUERY_PATHS = [ const PUBLIC_QUERY_PATH_SET = new Set(PUBLIC_QUERY_PATHS); -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 interface QueryRouteContext { + env: Env; + siteId: string; + url: URL; + options: { publicMode: boolean }; + request?: Request; + responseContext?: ResponseContext; +} - 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 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 && !PUBLIC_QUERY_PATH_SET.has(pathname)) { - 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 handleCrossBreakdown(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/hono/__tests__/app-routes.test.ts b/src/lib/hono/__tests__/app-routes.test.ts index 7460e380..c8c5ab7e 100644 --- a/src/lib/hono/__tests__/app-routes.test.ts +++ b/src/lib/hono/__tests__/app-routes.test.ts @@ -21,7 +21,7 @@ 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 { routeQuery } 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"; @@ -87,7 +87,7 @@ vi.mock("@/lib/edge/query/router", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - routeQuery: vi.fn(), + dispatchQueryRoute: vi.fn(), }; }); @@ -165,7 +165,9 @@ describe("Hono API app routing", () => { name: "Public Site", domain: "public.test", }); - vi.mocked(routeQuery).mockResolvedValue(new Response("private-query")); + vi.mocked(dispatchQueryRoute).mockResolvedValue( + new Response("private-query"), + ); vi.mocked(handlePublicQuery).mockResolvedValue( new Response("public-query"), ); @@ -286,7 +288,7 @@ describe("Hono API app routing", () => { expect(handlePrivateArchiveManifest).toHaveBeenCalled(); expect(handlePrivateArchive).not.toHaveBeenCalled(); expect(resolvePrivateSite).toHaveBeenCalled(); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "overview", diff --git a/src/lib/hono/__tests__/private-query-routes.test.ts b/src/lib/hono/__tests__/private-query-routes.test.ts index 4d2150d8..743f39e0 100644 --- a/src/lib/hono/__tests__/private-query-routes.test.ts +++ b/src/lib/hono/__tests__/private-query-routes.test.ts @@ -5,7 +5,7 @@ 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 { routeQuery } 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"; @@ -32,7 +32,7 @@ vi.mock("@/lib/edge/query/router", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - routeQuery: vi.fn(), + dispatchQueryRoute: vi.fn(), }; }); @@ -64,7 +64,7 @@ describe("Hono private query routes", () => { name: "Site", domain: "app.test", }); - vi.mocked(routeQuery).mockResolvedValue(new Response("query")); + vi.mocked(dispatchQueryRoute).mockResolvedValue(new Response("query")); vi.mocked(handleTeamDashboard).mockResolvedValue(new Response("team")); }); @@ -89,7 +89,7 @@ describe("Hono private query routes", () => { expect.any(Function), undefined, ); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "overview", @@ -114,7 +114,7 @@ describe("Hono private query routes", () => { expect(response.status).toBe(404); await expect(response.text()).resolves.toBe("denied"); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("keeps non-funnel mutations out of private query routes", async () => { @@ -129,7 +129,7 @@ describe("Hono private query routes", () => { expect(response.status).toBe(405); expect(resolvePrivateSite).not.toHaveBeenCalled(); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("allows funnel mutations without dashboard cache", async () => { @@ -149,7 +149,7 @@ describe("Hono private query routes", () => { expect(postResponse.status).toBe(200); expect(deleteResponse.status).toBe(200); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "funnels", @@ -176,7 +176,7 @@ describe("Hono private query routes", () => { ); expect(resolvePrivateSite).not.toHaveBeenCalled(); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("falls back unknown GET queries through the legacy query dispatcher", async () => { @@ -190,7 +190,7 @@ describe("Hono private query routes", () => { expect(response.status).toBe(200); expect(withDashboardCache).toHaveBeenCalled(); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "unknown", diff --git a/src/lib/hono/__tests__/public-query-routes.test.ts b/src/lib/hono/__tests__/public-query-routes.test.ts index 1a969076..8dc286a2 100644 --- a/src/lib/hono/__tests__/public-query-routes.test.ts +++ b/src/lib/hono/__tests__/public-query-routes.test.ts @@ -9,7 +9,7 @@ import { 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 { routeQuery } 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"; @@ -39,7 +39,7 @@ vi.mock("@/lib/edge/query/router", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - routeQuery: vi.fn(), + dispatchQueryRoute: vi.fn(), }; }); @@ -67,7 +67,7 @@ describe("Hono public query routes", () => { name: "Public Site", domain: "public.test", }); - vi.mocked(routeQuery).mockResolvedValue(new Response("query")); + vi.mocked(dispatchQueryRoute).mockResolvedValue(new Response("query")); }); it("returns public site metadata through the public cache wrapper", async () => { @@ -97,7 +97,7 @@ describe("Hono public query routes", () => { expect.any(Function), PUBLIC_QUERY_CACHE_OPTIONS, ); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("routes public allowlist queries with publicMode and public cache options", async () => { @@ -116,7 +116,7 @@ describe("Hono public query routes", () => { expect.any(Function), PUBLIC_QUERY_CACHE_OPTIONS, ); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "overview", @@ -138,7 +138,7 @@ describe("Hono public query routes", () => { expect(response.status).toBe(405); expect(fetchPublicSite).not.toHaveBeenCalled(); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("does not enter cache when public site resolution fails", async () => { @@ -156,11 +156,11 @@ describe("Hono public query routes", () => { expect(response.status).toBe(404); await expect(response.text()).resolves.toBe("missing"); expect(withDashboardCache).not.toHaveBeenCalled(); - expect(routeQuery).not.toHaveBeenCalled(); + expect(dispatchQueryRoute).not.toHaveBeenCalled(); }); it("keeps private-only endpoints behind the public query allowlist", async () => { - vi.mocked(routeQuery).mockResolvedValueOnce( + vi.mocked(dispatchQueryRoute).mockResolvedValueOnce( new Response("not found", { status: 404 }), ); const app = createApp(); @@ -172,7 +172,7 @@ describe("Hono public query routes", () => { ); expect(response.status).toBe(404); - expect(routeQuery).toHaveBeenCalledWith( + expect(dispatchQueryRoute).toHaveBeenCalledWith( env, "site-1", "events-records", diff --git a/src/lib/hono/routes/private/query.ts b/src/lib/hono/routes/private/query.ts index 1fea5271..defb1db4 100644 --- a/src/lib/hono/routes/private/query.ts +++ b/src/lib/hono/routes/private/query.ts @@ -2,7 +2,10 @@ import type { Context } from "hono"; import { Hono } from "hono"; import { notAllowed } from "@/lib/edge/query/core"; -import { DASHBOARD_QUERY_PATHS, routeQuery } from "@/lib/edge/query/router"; +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 { @@ -22,7 +25,7 @@ function privateQuery(pathname: string) { if (!site) { throw new Error("private site context missing"); } - return routeQuery( + return dispatchQueryRoute( c.env, site.id, pathname, diff --git a/src/lib/hono/routes/public/query.ts b/src/lib/hono/routes/public/query.ts index 1b0dfcaa..ec665e78 100644 --- a/src/lib/hono/routes/public/query.ts +++ b/src/lib/hono/routes/public/query.ts @@ -3,7 +3,10 @@ import { Hono } from "hono"; import { PUBLIC_QUERY_CACHE_OPTIONS } from "@/lib/edge/dashboard-cache"; import { jsonResponse } from "@/lib/edge/query/core"; -import { PUBLIC_QUERY_PATHS, routeQuery } from "@/lib/edge/query/router"; +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"; @@ -21,7 +24,7 @@ function publicQuery(pathname: string) { if (!site) { throw new Error("public site context missing"); } - return routeQuery( + return dispatchQueryRoute( c.env, site.id, pathname, From 2d4ce96b4434d70661905bc40f104dc20107a156 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:34:53 +0000 Subject: [PATCH 40/40] chore(deps): bump softprops/action-gh-release from 2 to 3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb3a2538..a72e214e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,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 }}