@byline/host-tanstack-start 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +2 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -2
- package/dist/integrations/analytics-agent.d.ts +20 -0
- package/dist/integrations/analytics-agent.js +57 -0
- package/dist/integrations/analytics-events.d.ts +51 -0
- package/dist/integrations/analytics-events.js +129 -0
- package/dist/integrations/serve-analytics-agent.d.ts +8 -0
- package/dist/integrations/serve-analytics-agent.js +11 -0
- package/dist/routes/analytics-range.d.ts +12 -0
- package/dist/routes/analytics-range.js +35 -0
- package/dist/routes/create-admin-analytics-route.d.ts +8 -0
- package/dist/routes/create-admin-analytics-route.js +108 -0
- package/dist/routes/create-admin-layout-route.js +10 -4
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/server-fns/analytics/index.d.ts +9 -0
- package/dist/server-fns/analytics/index.js +2 -0
- package/dist/server-fns/analytics/maintenance.d.ts +22 -0
- package/dist/server-fns/analytics/maintenance.js +42 -0
- package/dist/server-fns/analytics/queries.d.ts +25 -0
- package/dist/server-fns/analytics/queries.js +54 -0
- package/dist/vite/analytics-dev.d.ts +22 -0
- package/dist/vite/analytics-dev.js +28 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +1 -0
- package/package.json +14 -8
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +14 -0
- package/src/integrations/analytics-agent.tsx +91 -0
- package/src/integrations/analytics-events.test.node.ts +245 -0
- package/src/integrations/analytics-events.ts +224 -0
- package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
- package/src/integrations/serve-analytics-agent.ts +19 -0
- package/src/routes/analytics-range.ts +55 -0
- package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
- package/src/routes/create-admin-analytics-route.tsx +109 -0
- package/src/routes/create-admin-layout-route.tsx +15 -8
- package/src/routes/index.ts +1 -0
- package/src/server-fns/analytics/index.ts +23 -0
- package/src/server-fns/analytics/maintenance.ts +54 -0
- package/src/server-fns/analytics/queries.ts +86 -0
- package/src/vite/analytics-dev.test.node.ts +126 -0
- package/src/vite/analytics-dev.ts +56 -0
- package/src/vite/index.ts +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { AnalyticsCountryTotal, AnalyticsDateRange, AnalyticsEventKind, AnalyticsPathTotal, AnalyticsRankedTotals, AnalyticsReferrerTotal, AnalyticsReportCoverage, AnalyticsSummary } from '@byline/analytics';
|
|
9
|
+
export interface AnalyticsTopInput extends AnalyticsDateRange {
|
|
10
|
+
kind: AnalyticsEventKind;
|
|
11
|
+
limit?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface AnalyticsRuntimeState {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare const getAnalyticsRuntime: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<AnalyticsRuntimeState>>;
|
|
17
|
+
export declare const getAnalyticsSummary: import("@tanstack/react-start").RequiredFetcher<undefined, (input: AnalyticsDateRange) => AnalyticsDateRange, Promise<AnalyticsSummary>>;
|
|
18
|
+
export declare const getAnalyticsReportCoverage: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<AnalyticsReportCoverage>>;
|
|
19
|
+
export declare const getAnalyticsTop: import("@tanstack/react-start").RequiredFetcher<undefined, (input: AnalyticsTopInput) => AnalyticsTopInput, Promise<AnalyticsRankedTotals<AnalyticsPathTotal>>>;
|
|
20
|
+
export declare const getAnalyticsReferrers: import("@tanstack/react-start").RequiredFetcher<undefined, (input: AnalyticsDateRange & {
|
|
21
|
+
limit?: number;
|
|
22
|
+
}) => AnalyticsDateRange & {
|
|
23
|
+
limit?: number;
|
|
24
|
+
}, Promise<AnalyticsRankedTotals<AnalyticsReferrerTotal>>>;
|
|
25
|
+
export declare const getAnalyticsCountries: import("@tanstack/react-start").RequiredFetcher<undefined, (input: AnalyticsDateRange) => AnalyticsDateRange, Promise<AnalyticsCountryTotal[]>>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { assertAdminActor, requireAdminActor } from "@byline/admin";
|
|
3
|
+
import { ANALYTICS_ABILITIES } from "@byline/admin/analytics";
|
|
4
|
+
import { getAdminRequestContext } from "@byline/client/server";
|
|
5
|
+
const getAnalyticsRuntime = createServerFn({
|
|
6
|
+
method: 'GET'
|
|
7
|
+
}).handler(async ()=>{
|
|
8
|
+
const context = await getAdminRequestContext();
|
|
9
|
+
requireAdminActor(context, 'reading analytics runtime state');
|
|
10
|
+
const { isAnalyticsRegistered } = await import("@byline/analytics");
|
|
11
|
+
return {
|
|
12
|
+
enabled: isAnalyticsRegistered()
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
const getAnalyticsSummary = createServerFn({
|
|
16
|
+
method: 'GET'
|
|
17
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
18
|
+
await assertAnalyticsRead();
|
|
19
|
+
const { getAnalytics } = await import("@byline/analytics");
|
|
20
|
+
return getAnalytics().getSummary(data);
|
|
21
|
+
});
|
|
22
|
+
const getAnalyticsReportCoverage = createServerFn({
|
|
23
|
+
method: 'GET'
|
|
24
|
+
}).handler(async ()=>{
|
|
25
|
+
await assertAnalyticsRead();
|
|
26
|
+
const { getAnalytics } = await import("@byline/analytics");
|
|
27
|
+
return getAnalytics().getReportCoverage();
|
|
28
|
+
});
|
|
29
|
+
const getAnalyticsTop = createServerFn({
|
|
30
|
+
method: 'GET'
|
|
31
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
32
|
+
await assertAnalyticsRead();
|
|
33
|
+
const { getAnalytics } = await import("@byline/analytics");
|
|
34
|
+
return getAnalytics().getTopPaths(data);
|
|
35
|
+
});
|
|
36
|
+
const getAnalyticsReferrers = createServerFn({
|
|
37
|
+
method: 'GET'
|
|
38
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
39
|
+
await assertAnalyticsRead();
|
|
40
|
+
const { getAnalytics } = await import("@byline/analytics");
|
|
41
|
+
return getAnalytics().getReferrers(data);
|
|
42
|
+
});
|
|
43
|
+
const getAnalyticsCountries = createServerFn({
|
|
44
|
+
method: 'GET'
|
|
45
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
46
|
+
await assertAnalyticsRead();
|
|
47
|
+
const { getAnalytics } = await import("@byline/analytics");
|
|
48
|
+
return getAnalytics().getCountries(data);
|
|
49
|
+
});
|
|
50
|
+
async function assertAnalyticsRead() {
|
|
51
|
+
const context = await getAdminRequestContext();
|
|
52
|
+
assertAdminActor(context, ANALYTICS_ABILITIES.read);
|
|
53
|
+
}
|
|
54
|
+
export { getAnalyticsCountries, getAnalyticsReferrers, getAnalyticsReportCoverage, getAnalyticsRuntime, getAnalyticsSummary, getAnalyticsTop };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { Plugin } from 'vite';
|
|
9
|
+
/**
|
|
10
|
+
* Complete the first-party analytics transport during Vite development.
|
|
11
|
+
*
|
|
12
|
+
* Browser script requests carry `Sec-Fetch-Dest: script`, which Vite routes
|
|
13
|
+
* into its module pipeline before TanStack Start can serve the agent route.
|
|
14
|
+
* This plugin serves the agent directly. The application event route remains
|
|
15
|
+
* responsible for its development identity because POST server routes can
|
|
16
|
+
* bypass Vite's ordinary middleware chain. Production continues to use the
|
|
17
|
+
* application-owned agent route.
|
|
18
|
+
*/
|
|
19
|
+
export declare function bylineAnalyticsDev(options?: {
|
|
20
|
+
/** Public path that serves the standalone agent. */
|
|
21
|
+
agentPath?: string;
|
|
22
|
+
}): Plugin;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { serveAnalyticsAgent } from "../integrations/serve-analytics-agent.js";
|
|
2
|
+
function bylineAnalyticsDev(options = {}) {
|
|
3
|
+
const agentPath = options.agentPath ?? '/b.js';
|
|
4
|
+
return {
|
|
5
|
+
name: 'byline:analytics-dev',
|
|
6
|
+
apply: 'serve',
|
|
7
|
+
configureServer (server) {
|
|
8
|
+
server.middlewares.use(async (request, response, next)=>{
|
|
9
|
+
const pathname = request.url?.split('?', 1)[0];
|
|
10
|
+
if ('GET' === request.method && pathname === agentPath) {
|
|
11
|
+
try {
|
|
12
|
+
const agentResponse = serveAnalyticsAgent();
|
|
13
|
+
response.statusCode = agentResponse.status;
|
|
14
|
+
agentResponse.headers.forEach((value, name)=>{
|
|
15
|
+
response.setHeader(name, value);
|
|
16
|
+
});
|
|
17
|
+
response.end(await agentResponse.text());
|
|
18
|
+
} catch (error) {
|
|
19
|
+
next(error);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export { bylineAnalyticsDev };
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.16.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./dist/server-fns/admin-account/index.d.ts",
|
|
46
46
|
"import": "./dist/server-fns/admin-account/index.js"
|
|
47
47
|
},
|
|
48
|
+
"./server-fns/analytics": {
|
|
49
|
+
"types": "./dist/server-fns/analytics/index.d.ts",
|
|
50
|
+
"import": "./dist/server-fns/analytics/index.js"
|
|
51
|
+
},
|
|
48
52
|
"./server-fns/admin-activity": {
|
|
49
53
|
"types": "./dist/server-fns/admin-activity/index.d.ts",
|
|
50
54
|
"import": "./dist/server-fns/admin-activity/index.js"
|
|
@@ -117,13 +121,15 @@
|
|
|
117
121
|
"react-json-view-lite": "^2.5.0",
|
|
118
122
|
"react-swipeable": "^7.0.2",
|
|
119
123
|
"zod": "^4.4.3",
|
|
120
|
-
"@byline/admin": "4.
|
|
121
|
-
"@byline/
|
|
122
|
-
"@byline/ai": "4.
|
|
123
|
-
"@byline/
|
|
124
|
-
"@byline/
|
|
125
|
-
"@byline/
|
|
126
|
-
"@byline/
|
|
124
|
+
"@byline/admin": "4.16.0",
|
|
125
|
+
"@byline/analytics-agent": "4.16.0",
|
|
126
|
+
"@byline/ai": "4.16.0",
|
|
127
|
+
"@byline/analytics": "4.16.0",
|
|
128
|
+
"@byline/client": "4.16.0",
|
|
129
|
+
"@byline/core": "4.16.0",
|
|
130
|
+
"@byline/auth": "4.16.0",
|
|
131
|
+
"@byline/i18n": "4.16.0",
|
|
132
|
+
"@byline/ui": "4.16.0"
|
|
127
133
|
},
|
|
128
134
|
"peerDependencies": {
|
|
129
135
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -13,10 +13,12 @@ import { ADMIN_ACTIVITY_ABILITIES } from '@byline/admin/admin-activity'
|
|
|
13
13
|
import { ADMIN_PERMISSIONS_ABILITIES } from '@byline/admin/admin-permissions'
|
|
14
14
|
import { ADMIN_ROLES_ABILITIES } from '@byline/admin/admin-roles'
|
|
15
15
|
import { ADMIN_USERS_ABILITIES } from '@byline/admin/admin-users'
|
|
16
|
+
import { ANALYTICS_ABILITIES } from '@byline/admin/analytics'
|
|
16
17
|
import { collectionAbilityKey, getAdminConfig } from '@byline/core'
|
|
17
18
|
import { useTranslation } from '@byline/i18n/react'
|
|
18
19
|
import {
|
|
19
20
|
ActivityIcon,
|
|
21
|
+
AnalyticsIcon,
|
|
20
22
|
CalendarIcon,
|
|
21
23
|
HomeIcon,
|
|
22
24
|
RolesIcon,
|
|
@@ -78,8 +80,10 @@ function MenuItem({ to, label, icon, pathname, compact }: MenuItemProps) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export function AdminMenuDrawer({
|
|
83
|
+
analyticsEnabled = false,
|
|
81
84
|
scheduledPublicationEnabled = false,
|
|
82
85
|
}: {
|
|
86
|
+
analyticsEnabled?: boolean
|
|
83
87
|
scheduledPublicationEnabled?: boolean
|
|
84
88
|
}): React.JSX.Element | null {
|
|
85
89
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
@@ -96,6 +100,7 @@ export function AdminMenuDrawer({
|
|
|
96
100
|
const canReadRoles = has(ADMIN_ROLES_ABILITIES.read)
|
|
97
101
|
const canReadPermissions = has(ADMIN_PERMISSIONS_ABILITIES.read)
|
|
98
102
|
const canReadActivity = has(ADMIN_ACTIVITY_ABILITIES.read)
|
|
103
|
+
const canReadAnalytics = analyticsEnabled && has(ANALYTICS_ABILITIES.read)
|
|
99
104
|
const canSeeScheduledPublications =
|
|
100
105
|
scheduledPublicationEnabled &&
|
|
101
106
|
getAdminConfig().collections.some(
|
|
@@ -152,6 +157,15 @@ export function AdminMenuDrawer({
|
|
|
152
157
|
compact={compact}
|
|
153
158
|
/>
|
|
154
159
|
)}
|
|
160
|
+
{canReadAnalytics && (
|
|
161
|
+
<MenuItem
|
|
162
|
+
to={getAdminRoutePath('analytics')}
|
|
163
|
+
label={t('chrome.menu.analytics')}
|
|
164
|
+
icon={<AnalyticsIcon width="20px" height="20px" />}
|
|
165
|
+
pathname={pathname}
|
|
166
|
+
compact={compact}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
155
169
|
{showAdminSection && (
|
|
156
170
|
<>
|
|
157
171
|
<li className="menu-separator" />
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type React from 'react'
|
|
12
|
+
import { useEffect, useRef } from 'react'
|
|
13
|
+
import { useRouterState } from '@tanstack/react-router'
|
|
14
|
+
|
|
15
|
+
import { ANALYTICS_NAVIGATION_EVENT, ANALYTICS_STOP_EVENT } from '@byline/analytics-agent'
|
|
16
|
+
|
|
17
|
+
let pendingStopTimer: number | undefined
|
|
18
|
+
|
|
19
|
+
export interface AnalyticsAgentProps {
|
|
20
|
+
/** Application-owned same-origin route that receives browser events. */
|
|
21
|
+
endpoint: string
|
|
22
|
+
src?: string
|
|
23
|
+
cdnHosts?: readonly string[]
|
|
24
|
+
downloadExtensions?: readonly string[]
|
|
25
|
+
ignoredPathPrefixes?: readonly string[]
|
|
26
|
+
countSearchChanges?: boolean
|
|
27
|
+
countHashChanges?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Render the standalone script and bridge only committed router locations into it. */
|
|
31
|
+
export function AnalyticsAgent({
|
|
32
|
+
endpoint,
|
|
33
|
+
src = '/b.js',
|
|
34
|
+
cdnHosts,
|
|
35
|
+
downloadExtensions,
|
|
36
|
+
ignoredPathPrefixes,
|
|
37
|
+
countSearchChanges = false,
|
|
38
|
+
countHashChanges = false,
|
|
39
|
+
}: AnalyticsAgentProps): React.JSX.Element {
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<script
|
|
43
|
+
defer
|
|
44
|
+
src={src}
|
|
45
|
+
data-endpoint={endpoint}
|
|
46
|
+
data-cdn-hosts={boundedAttribute(cdnHosts)}
|
|
47
|
+
data-download-extensions={boundedAttribute(downloadExtensions)}
|
|
48
|
+
data-ignore-prefixes={boundedAttribute(ignoredPathPrefixes)}
|
|
49
|
+
data-count-search={countSearchChanges ? 'true' : undefined}
|
|
50
|
+
data-count-hash={countHashChanges ? 'true' : undefined}
|
|
51
|
+
/>
|
|
52
|
+
<AnalyticsNavigationBridge />
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function AnalyticsNavigationBridge(): null {
|
|
58
|
+
const href = useRouterState({ select: (state) => state.location.href })
|
|
59
|
+
const previousHref = useRef<string | undefined>(undefined)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const previous = previousHref.current
|
|
63
|
+
previousHref.current = href
|
|
64
|
+
if (previous == null || previous === href) return
|
|
65
|
+
document.dispatchEvent(new CustomEvent(ANALYTICS_NAVIGATION_EVENT, { detail: href }))
|
|
66
|
+
}, [href])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (pendingStopTimer !== undefined) {
|
|
70
|
+
window.clearTimeout(pendingStopTimer)
|
|
71
|
+
pendingStopTimer = undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
// React Strict Mode immediately re-runs this effect after its simulated
|
|
76
|
+
// cleanup. Deferring the stop lets that setup cancel it, while a real
|
|
77
|
+
// consent-driven unmount leaves the timer in place.
|
|
78
|
+
pendingStopTimer = window.setTimeout(() => {
|
|
79
|
+
pendingStopTimer = undefined
|
|
80
|
+
document.dispatchEvent(new Event(ANALYTICS_STOP_EVENT))
|
|
81
|
+
}, 0)
|
|
82
|
+
}
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function boundedAttribute(values: readonly string[] | undefined): string | undefined {
|
|
89
|
+
if (values == null || values.length === 0) return undefined
|
|
90
|
+
return values.slice(0, 64).join(',').slice(0, 2_048)
|
|
91
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createAnalyticsEventHandler,
|
|
13
|
+
localAnalyticsRequestContext,
|
|
14
|
+
trustedAnalyticsHeaders,
|
|
15
|
+
} from './analytics-events.js'
|
|
16
|
+
|
|
17
|
+
describe('createAnalyticsEventHandler', () => {
|
|
18
|
+
it('passes only explicit trusted request facts and emits no CORS response header', async () => {
|
|
19
|
+
const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
|
|
20
|
+
const handler = createAnalyticsEventHandler({
|
|
21
|
+
resolveRequestContext: trustedAnalyticsHeaders({
|
|
22
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
23
|
+
countryHeader: 'x-byline-client-country',
|
|
24
|
+
}),
|
|
25
|
+
analytics: () => ({ ingest }),
|
|
26
|
+
})
|
|
27
|
+
const body = JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' })
|
|
28
|
+
const response = await handler(
|
|
29
|
+
new Request('https://www.example.com/api/events', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
body,
|
|
32
|
+
headers: {
|
|
33
|
+
origin: 'https://www.example.com',
|
|
34
|
+
'user-agent': 'Mozilla/5.0',
|
|
35
|
+
'x-byline-client-country': 'TH',
|
|
36
|
+
'x-byline-client-ip': '203.0.113.10',
|
|
37
|
+
'x-forwarded-for': '198.51.100.99',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(response.status).toBe(202)
|
|
43
|
+
expect(response.headers.has('access-control-allow-origin')).toBe(false)
|
|
44
|
+
expect(ingest).toHaveBeenCalledOnce()
|
|
45
|
+
expect(ingest.mock.calls[0]?.[0]).toMatchObject({
|
|
46
|
+
clientIp: '203.0.113.10',
|
|
47
|
+
country: 'TH',
|
|
48
|
+
origin: 'https://www.example.com',
|
|
49
|
+
})
|
|
50
|
+
expect(ingest.mock.calls[0]?.[0]).not.toHaveProperty('xForwardedFor')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('stops reading an oversized stream and reports the contract body-size rejection', async () => {
|
|
54
|
+
const ingest = vi.fn().mockResolvedValue({ status: 400, accepted: false, reason: 'body-size' })
|
|
55
|
+
const handler = createAnalyticsEventHandler({
|
|
56
|
+
resolveRequestContext: trustedAnalyticsHeaders({
|
|
57
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
58
|
+
}),
|
|
59
|
+
analytics: () => ({ ingest }),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const response = await handler(
|
|
63
|
+
new Request('https://www.example.com/api/events', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
body: 'x'.repeat(1_025),
|
|
66
|
+
headers: { 'x-byline-client-ip': '203.0.113.10' },
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(response.status).toBe(400)
|
|
71
|
+
const received = ingest.mock.calls.at(0)?.at(0)
|
|
72
|
+
expect(received).toBeDefined()
|
|
73
|
+
expect((received?.body as Uint8Array | undefined)?.byteLength).toBe(1_025)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('uses an explicit development identity only when the trusted header is absent', async () => {
|
|
77
|
+
const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
|
|
78
|
+
const handler = createAnalyticsEventHandler({
|
|
79
|
+
resolveRequestContext: trustedAnalyticsHeaders({
|
|
80
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
81
|
+
fallback: { clientIp: 'vite-development' },
|
|
82
|
+
}),
|
|
83
|
+
analytics: () => ({ ingest }),
|
|
84
|
+
})
|
|
85
|
+
const body = JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' })
|
|
86
|
+
|
|
87
|
+
await handler(
|
|
88
|
+
new Request('http://localhost:5173/api/events', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
body,
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
await handler(
|
|
94
|
+
new Request('http://localhost:5173/api/events', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body,
|
|
97
|
+
headers: { 'x-byline-client-ip': '203.0.113.10' },
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(ingest.mock.calls[0]?.[0].clientIp).toBe('vite-development')
|
|
102
|
+
expect(ingest.mock.calls[1]?.[0].clientIp).toBe('203.0.113.10')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it.each(['127.0.0.1', '::1', '::ffff:127.0.0.1'])(
|
|
106
|
+
'accepts direct loopback identity %s for a localhost production preview',
|
|
107
|
+
async (clientIp) => {
|
|
108
|
+
const resolver = localAnalyticsRequestContext({ resolveClientIp: () => clientIp })
|
|
109
|
+
|
|
110
|
+
await expect(resolver(new Request('http://localhost:5173/api/events'))).resolves.toEqual({
|
|
111
|
+
clientIp,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
it('requires both a loopback URL and a loopback direct peer', async () => {
|
|
117
|
+
const loopbackPeer = localAnalyticsRequestContext({ resolveClientIp: () => '127.0.0.1' })
|
|
118
|
+
const publicPeer = localAnalyticsRequestContext({ resolveClientIp: () => '203.0.113.10' })
|
|
119
|
+
|
|
120
|
+
await expect(loopbackPeer(new Request('https://www.example.com/api/events'))).resolves.toEqual(
|
|
121
|
+
{}
|
|
122
|
+
)
|
|
123
|
+
await expect(publicPeer(new Request('http://localhost:5173/api/events'))).resolves.toEqual({})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('prefers the trusted proxy header over a local fallback resolver', async () => {
|
|
127
|
+
const resolver = trustedAnalyticsHeaders({
|
|
128
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
129
|
+
fallback: localAnalyticsRequestContext({ resolveClientIp: () => '127.0.0.1' }),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await expect(
|
|
133
|
+
resolver(
|
|
134
|
+
new Request('http://localhost:5173/api/events', {
|
|
135
|
+
headers: { 'x-byline-client-ip': '203.0.113.10' },
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
).resolves.toEqual({ clientIp: '203.0.113.10', country: undefined })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('rejects an empty development identity', () => {
|
|
142
|
+
expect(() =>
|
|
143
|
+
trustedAnalyticsHeaders({
|
|
144
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
145
|
+
fallback: { clientIp: ' ' },
|
|
146
|
+
})
|
|
147
|
+
).toThrow('fallback.clientIp must contain 1 to 255 characters')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('rejects an invalid configured trust header before serving requests', () => {
|
|
151
|
+
expect(() =>
|
|
152
|
+
trustedAnalyticsHeaders({
|
|
153
|
+
clientIpHeader: 'not a header:',
|
|
154
|
+
})
|
|
155
|
+
).toThrow('valid HTTP header name')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('accepts deployment facts from a host resolver without assuming a proxy', async () => {
|
|
159
|
+
const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
|
|
160
|
+
const resolveRequestContext = vi.fn().mockReturnValue({
|
|
161
|
+
clientIp: 'direct-platform-client',
|
|
162
|
+
country: 'NZ',
|
|
163
|
+
})
|
|
164
|
+
const handler = createAnalyticsEventHandler({
|
|
165
|
+
resolveRequestContext,
|
|
166
|
+
analytics: () => ({ ingest }),
|
|
167
|
+
})
|
|
168
|
+
const request = new Request('https://www.example.com/telemetry/events', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect((await handler(request)).status).toBe(202)
|
|
174
|
+
expect(resolveRequestContext).toHaveBeenCalledWith(request)
|
|
175
|
+
expect(ingest.mock.calls[0]?.[0]).toMatchObject({
|
|
176
|
+
clientIp: 'direct-platform-client',
|
|
177
|
+
country: 'NZ',
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('contains persistence failures as an empty 503 and logs no request facts', async () => {
|
|
182
|
+
const visitorHash = 'a'.repeat(64)
|
|
183
|
+
const error = Object.assign(new Error('new row violates analytics constraint'), {
|
|
184
|
+
code: '23514',
|
|
185
|
+
detail: `Failing row contains visitor ${visitorHash}`,
|
|
186
|
+
})
|
|
187
|
+
const ingest = vi.fn().mockRejectedValue(error)
|
|
188
|
+
const logError = vi.fn()
|
|
189
|
+
const handler = createAnalyticsEventHandler({
|
|
190
|
+
resolveRequestContext: trustedAnalyticsHeaders({
|
|
191
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
192
|
+
}),
|
|
193
|
+
analytics: () => ({ ingest }),
|
|
194
|
+
logger: () => ({ error: logError }),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const response = await handler(
|
|
198
|
+
new Request('https://www.example.com/api/events', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
|
|
201
|
+
headers: {
|
|
202
|
+
origin: 'https://www.example.com',
|
|
203
|
+
'user-agent': 'Mozilla/5.0',
|
|
204
|
+
'x-byline-client-ip': '203.0.113.10',
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
expect(response.status).toBe(503)
|
|
210
|
+
expect(await response.text()).toBe('')
|
|
211
|
+
expect(logError).toHaveBeenCalledWith(
|
|
212
|
+
{ errorCode: '23514' },
|
|
213
|
+
'[analytics] event persistence failed'
|
|
214
|
+
)
|
|
215
|
+
const logged = JSON.stringify(logError.mock.calls)
|
|
216
|
+
expect(logged).not.toContain('203.0.113.10')
|
|
217
|
+
expect(logged).not.toContain(visitorHash)
|
|
218
|
+
expect(logged).not.toContain(error.message)
|
|
219
|
+
expect(logged).not.toContain(error.detail)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('does not trust arbitrary persistence error codes', async () => {
|
|
223
|
+
const ingest = vi.fn().mockRejectedValue({ code: 'unsafe value: 203.0.113.10' })
|
|
224
|
+
const logError = vi.fn()
|
|
225
|
+
const handler = createAnalyticsEventHandler({
|
|
226
|
+
resolveRequestContext: trustedAnalyticsHeaders({
|
|
227
|
+
clientIpHeader: 'x-byline-client-ip',
|
|
228
|
+
}),
|
|
229
|
+
analytics: () => ({ ingest }),
|
|
230
|
+
logger: () => ({ error: logError }),
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await handler(
|
|
234
|
+
new Request('https://www.example.com/api/events', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
|
|
237
|
+
})
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
expect(logError).toHaveBeenCalledWith(
|
|
241
|
+
{ errorCode: 'UNKNOWN' },
|
|
242
|
+
'[analytics] event persistence failed'
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
})
|