@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.
Files changed (43) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +2 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -2
  3. package/dist/integrations/analytics-agent.d.ts +20 -0
  4. package/dist/integrations/analytics-agent.js +57 -0
  5. package/dist/integrations/analytics-events.d.ts +51 -0
  6. package/dist/integrations/analytics-events.js +129 -0
  7. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  8. package/dist/integrations/serve-analytics-agent.js +11 -0
  9. package/dist/routes/analytics-range.d.ts +12 -0
  10. package/dist/routes/analytics-range.js +35 -0
  11. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  12. package/dist/routes/create-admin-analytics-route.js +108 -0
  13. package/dist/routes/create-admin-layout-route.js +10 -4
  14. package/dist/routes/index.d.ts +1 -0
  15. package/dist/routes/index.js +1 -0
  16. package/dist/server-fns/analytics/index.d.ts +9 -0
  17. package/dist/server-fns/analytics/index.js +2 -0
  18. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  19. package/dist/server-fns/analytics/maintenance.js +42 -0
  20. package/dist/server-fns/analytics/queries.d.ts +25 -0
  21. package/dist/server-fns/analytics/queries.js +54 -0
  22. package/dist/vite/analytics-dev.d.ts +22 -0
  23. package/dist/vite/analytics-dev.js +28 -0
  24. package/dist/vite/index.d.ts +1 -0
  25. package/dist/vite/index.js +1 -0
  26. package/package.json +14 -8
  27. package/src/admin-shell/chrome/admin-menu-drawer.tsx +14 -0
  28. package/src/integrations/analytics-agent.tsx +91 -0
  29. package/src/integrations/analytics-events.test.node.ts +245 -0
  30. package/src/integrations/analytics-events.ts +224 -0
  31. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  32. package/src/integrations/serve-analytics-agent.ts +19 -0
  33. package/src/routes/analytics-range.ts +55 -0
  34. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  35. package/src/routes/create-admin-analytics-route.tsx +109 -0
  36. package/src/routes/create-admin-layout-route.tsx +15 -8
  37. package/src/routes/index.ts +1 -0
  38. package/src/server-fns/analytics/index.ts +23 -0
  39. package/src/server-fns/analytics/maintenance.ts +54 -0
  40. package/src/server-fns/analytics/queries.ts +86 -0
  41. package/src/vite/analytics-dev.test.node.ts +126 -0
  42. package/src/vite/analytics-dev.ts +56 -0
  43. 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 };
@@ -5,4 +5,5 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ export { bylineAnalyticsDev } from './analytics-dev.js';
8
9
  export { bylineClientHookBoundary } from './client-hook-boundary.js';
@@ -1 +1,2 @@
1
+ export { bylineAnalyticsDev } from "./analytics-dev.js";
1
2
  export { bylineClientHookBoundary } from "./client-hook-boundary.js";
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.15.0",
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.15.0",
121
- "@byline/auth": "4.15.0",
122
- "@byline/ai": "4.15.0",
123
- "@byline/ui": "4.15.0",
124
- "@byline/core": "4.15.0",
125
- "@byline/client": "4.15.0",
126
- "@byline/i18n": "4.15.0"
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
+ })