@byline/host-tanstack-start 4.14.1 → 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 +4 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +26 -2
- package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
- package/dist/admin-shell/collections/edit.js +142 -2
- package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
- package/dist/admin-shell/scheduled-publications/list.js +295 -0
- package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
- package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
- 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 +15 -4
- package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
- package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -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/server-fns/collections/get.d.ts +5 -0
- package/dist/server-fns/collections/get.js +19 -3
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
- package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
- package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
- package/dist/server-fns/collections/scheduled-publication.js +157 -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.module.css +7 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +37 -1
- package/src/admin-shell/collections/edit.tsx +140 -1
- package/src/admin-shell/scheduled-publications/list.module.css +105 -0
- package/src/admin-shell/scheduled-publications/list.tsx +292 -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 +19 -7
- package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
- package/src/routes/index.ts +2 -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/server-fns/collections/get.ts +30 -1
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
- package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
- package/src/server-fns/collections/scheduled-publication.ts +270 -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,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
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
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 { ANALYTICS_MAX_BODY_BYTES, type Analytics, getAnalytics } from '@byline/analytics'
|
|
10
|
+
import { type BylineLogger, getLogger } from '@byline/core'
|
|
11
|
+
|
|
12
|
+
export interface AnalyticsEventHandlerOptions {
|
|
13
|
+
/** Host-owned resolver for deployment-specific, request-scoped network facts. */
|
|
14
|
+
resolveRequestContext: AnalyticsRequestContextResolver
|
|
15
|
+
/** Test/embedding seam; production resolves the registered installation runtime. */
|
|
16
|
+
analytics?: () => Pick<Analytics, 'ingest'>
|
|
17
|
+
/** Test/embedding seam; production resolves the initialized application logger. */
|
|
18
|
+
logger?: () => Pick<BylineLogger, 'error'>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AnalyticsRequestContext {
|
|
22
|
+
clientIp?: string | null
|
|
23
|
+
country?: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AnalyticsRequestContextResolver = (
|
|
27
|
+
request: Request
|
|
28
|
+
) => AnalyticsRequestContext | Promise<AnalyticsRequestContext>
|
|
29
|
+
|
|
30
|
+
export interface LocalAnalyticsRequestContextOptions {
|
|
31
|
+
/** Resolve the direct peer address from the host runtime, without forwarded headers. */
|
|
32
|
+
resolveClientIp: (
|
|
33
|
+
request: Request
|
|
34
|
+
) => string | null | undefined | Promise<string | null | undefined>
|
|
35
|
+
/** Explicit development-only identity for runtimes that expose no peer address. */
|
|
36
|
+
developmentFallbackClientIp?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrustedAnalyticsHeadersOptions {
|
|
40
|
+
/** Header overwritten by the trusted reverse proxy with the normalized client address. */
|
|
41
|
+
clientIpHeader: string
|
|
42
|
+
/** Optional header overwritten by the trusted edge with an ISO country code. */
|
|
43
|
+
countryHeader?: string
|
|
44
|
+
/** Explicit context or resolver used only when a trusted header is absent. */
|
|
45
|
+
fallback?: AnalyticsRequestContext | AnalyticsRequestContextResolver
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an identity for a server reached directly through a loopback URL.
|
|
50
|
+
* Both the URL and the direct peer address must be loopback. This is safe for
|
|
51
|
+
* a directly reached local server. A same-host reverse proxy also appears as
|
|
52
|
+
* a loopback peer, however, so do not expose this fallback through a public
|
|
53
|
+
* proxy where a client can select the Host header.
|
|
54
|
+
*/
|
|
55
|
+
export function localAnalyticsRequestContext(
|
|
56
|
+
options: LocalAnalyticsRequestContextOptions
|
|
57
|
+
): AnalyticsRequestContextResolver {
|
|
58
|
+
const developmentFallbackClientIp =
|
|
59
|
+
options.developmentFallbackClientIp == null
|
|
60
|
+
? undefined
|
|
61
|
+
: validateFact(options.developmentFallbackClientIp, 'developmentFallbackClientIp', 255)
|
|
62
|
+
|
|
63
|
+
return async (request) => {
|
|
64
|
+
const clientIp = await options.resolveClientIp(request)
|
|
65
|
+
if (isLoopbackHostname(new URL(request.url).hostname) && isLoopbackAddress(clientIp)) {
|
|
66
|
+
return { clientIp: validateFact(clientIp, 'resolved clientIp', 255) }
|
|
67
|
+
}
|
|
68
|
+
return developmentFallbackClientIp == null ? {} : { clientIp: developmentFallbackClientIp }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve network facts from headers owned and overwritten by a trusted proxy.
|
|
74
|
+
* This helper does not establish that trust: the deployment must prevent a
|
|
75
|
+
* client from selecting the configured header values or bypassing the proxy.
|
|
76
|
+
*/
|
|
77
|
+
export function trustedAnalyticsHeaders(
|
|
78
|
+
options: TrustedAnalyticsHeadersOptions
|
|
79
|
+
): AnalyticsRequestContextResolver {
|
|
80
|
+
const clientIpHeader = validateHeaderName(options.clientIpHeader, 'clientIpHeader')
|
|
81
|
+
const countryHeader =
|
|
82
|
+
options.countryHeader == null
|
|
83
|
+
? undefined
|
|
84
|
+
: validateHeaderName(options.countryHeader, 'countryHeader')
|
|
85
|
+
const fallback =
|
|
86
|
+
typeof options.fallback === 'function' ? options.fallback : validateFallback(options.fallback)
|
|
87
|
+
|
|
88
|
+
return async (request) => {
|
|
89
|
+
const fallbackContext =
|
|
90
|
+
typeof fallback === 'function' ? validateFallback(await fallback(request)) : fallback
|
|
91
|
+
return {
|
|
92
|
+
clientIp: request.headers.get(clientIpHeader)?.trim() || fallbackContext?.clientIp,
|
|
93
|
+
country:
|
|
94
|
+
(countryHeader == null ? null : request.headers.get(countryHeader)?.trim()) ||
|
|
95
|
+
fallbackContext?.country,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createAnalyticsEventHandler(
|
|
101
|
+
options: AnalyticsEventHandlerOptions
|
|
102
|
+
): (request: Request) => Promise<Response> {
|
|
103
|
+
const resolveAnalytics = options.analytics ?? getAnalytics
|
|
104
|
+
|
|
105
|
+
return async (request) => {
|
|
106
|
+
const body = await readBoundedBody(request, ANALYTICS_MAX_BODY_BYTES)
|
|
107
|
+
try {
|
|
108
|
+
const requestContext = await options.resolveRequestContext(request)
|
|
109
|
+
const result = await resolveAnalytics().ingest({
|
|
110
|
+
method: request.method,
|
|
111
|
+
body: body ?? new Uint8Array(ANALYTICS_MAX_BODY_BYTES + 1),
|
|
112
|
+
origin: request.headers.get('origin'),
|
|
113
|
+
referer: request.headers.get('referer'),
|
|
114
|
+
userAgent: request.headers.get('user-agent'),
|
|
115
|
+
secPurpose: request.headers.get('sec-purpose'),
|
|
116
|
+
xPurpose: request.headers.get('x-purpose'),
|
|
117
|
+
clientIp: requestContext.clientIp,
|
|
118
|
+
country: requestContext.country,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return analyticsResponse(result.status)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const logger = options.logger?.() ?? getLogger()
|
|
124
|
+
logger.error({ errorCode: safeErrorCode(error) }, '[analytics] event persistence failed')
|
|
125
|
+
return analyticsResponse(503)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function safeErrorCode(error: unknown): string {
|
|
131
|
+
if (error == null || typeof error !== 'object' || !('code' in error)) return 'UNKNOWN'
|
|
132
|
+
const code = error.code
|
|
133
|
+
return typeof code === 'string' && /^[a-z0-9_-]{1,64}$/iu.test(code) ? code : 'UNKNOWN'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function analyticsResponse(status: number): Response {
|
|
137
|
+
return new Response(null, {
|
|
138
|
+
status,
|
|
139
|
+
headers: { 'cache-control': 'no-store' },
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readBoundedBody(request: Request, limit: number): Promise<Uint8Array | null> {
|
|
144
|
+
const declaredLength = Number(request.headers.get('content-length'))
|
|
145
|
+
if (Number.isFinite(declaredLength) && declaredLength > limit) return null
|
|
146
|
+
if (request.body == null) return new Uint8Array()
|
|
147
|
+
|
|
148
|
+
const reader = request.body.getReader()
|
|
149
|
+
const chunks: Uint8Array[] = []
|
|
150
|
+
let length = 0
|
|
151
|
+
try {
|
|
152
|
+
while (true) {
|
|
153
|
+
const { done, value } = await reader.read()
|
|
154
|
+
if (done) break
|
|
155
|
+
length += value.byteLength
|
|
156
|
+
if (length > limit) {
|
|
157
|
+
await reader.cancel()
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
chunks.push(value)
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
reader.releaseLock()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = new Uint8Array(length)
|
|
167
|
+
let offset = 0
|
|
168
|
+
for (const chunk of chunks) {
|
|
169
|
+
body.set(chunk, offset)
|
|
170
|
+
offset += chunk.byteLength
|
|
171
|
+
}
|
|
172
|
+
return body
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateHeaderName(value: string, label: string): string {
|
|
176
|
+
const normalized = value.trim().toLowerCase()
|
|
177
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/u.test(normalized)) {
|
|
178
|
+
throw new Error(`analytics ${label} must be a valid HTTP header name`)
|
|
179
|
+
}
|
|
180
|
+
return normalized
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateFallback(
|
|
184
|
+
value: AnalyticsRequestContext | undefined
|
|
185
|
+
): AnalyticsRequestContext | undefined {
|
|
186
|
+
if (value == null) return undefined
|
|
187
|
+
return {
|
|
188
|
+
...(value.clientIp == null
|
|
189
|
+
? {}
|
|
190
|
+
: { clientIp: validateFact(value.clientIp, 'fallback.clientIp', 255) }),
|
|
191
|
+
...(value.country == null
|
|
192
|
+
? {}
|
|
193
|
+
: { country: validateFact(value.country, 'fallback.country', 16) }),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateFact(value: string, label: string, maxLength: number): string {
|
|
198
|
+
const normalized = value.trim()
|
|
199
|
+
if (normalized.length === 0 || normalized.length > maxLength) {
|
|
200
|
+
throw new Error(`analytics ${label} must contain 1 to ${maxLength} characters`)
|
|
201
|
+
}
|
|
202
|
+
return normalized
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isLoopbackHostname(value: string): boolean {
|
|
206
|
+
const normalized = stripIpv6Brackets(value.toLowerCase())
|
|
207
|
+
return (
|
|
208
|
+
normalized === 'localhost' || normalized.endsWith('.localhost') || isLoopbackAddress(normalized)
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isLoopbackAddress(value: string | null | undefined): value is string {
|
|
213
|
+
if (value == null) return false
|
|
214
|
+
const normalized = stripIpv6Brackets(value.trim().toLowerCase())
|
|
215
|
+
return (
|
|
216
|
+
normalized === '::1' ||
|
|
217
|
+
/^127(?:\.\d{1,3}){3}$/u.test(normalized) ||
|
|
218
|
+
/^::ffff:127(?:\.\d{1,3}){3}$/u.test(normalized)
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stripIpv6Brackets(value: string): string {
|
|
223
|
+
return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value
|
|
224
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { serveAnalyticsAgent } from './serve-analytics-agent.js'
|
|
12
|
+
|
|
13
|
+
describe('serveAnalyticsAgent', () => {
|
|
14
|
+
it('serves the bundled standalone collector with bounded first-party caching', async () => {
|
|
15
|
+
const response = serveAnalyticsAgent()
|
|
16
|
+
expect(response.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
|
|
17
|
+
expect(response.headers.get('cache-control')).toBe('public, max-age=3600, must-revalidate')
|
|
18
|
+
expect(await response.text()).toContain('text/plain')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
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 { ANALYTICS_AGENT_SOURCE } from '@byline/analytics-agent/source'
|
|
10
|
+
|
|
11
|
+
export function serveAnalyticsAgent(): Response {
|
|
12
|
+
return new Response(ANALYTICS_AGENT_SOURCE, {
|
|
13
|
+
headers: {
|
|
14
|
+
'cache-control': 'public, max-age=3600, must-revalidate',
|
|
15
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
16
|
+
'x-content-type-options': 'nosniff',
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 type { AnalyticsDashboardPeriod } from '@byline/admin/analytics'
|
|
10
|
+
|
|
11
|
+
const UTC_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u
|
|
12
|
+
|
|
13
|
+
export function buildAnalyticsDashboardRange(
|
|
14
|
+
period: AnalyticsDashboardPeriod,
|
|
15
|
+
now = new Date(),
|
|
16
|
+
earliestReportDay: string | null = null
|
|
17
|
+
): { from: string; to: string } {
|
|
18
|
+
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
|
19
|
+
|
|
20
|
+
if (period === 'ytd') {
|
|
21
|
+
return {
|
|
22
|
+
from: formatUtcDay(new Date(Date.UTC(to.getUTCFullYear(), 0, 1))),
|
|
23
|
+
to: formatUtcDay(to),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (period === 'all') {
|
|
28
|
+
const toDay = formatUtcDay(to)
|
|
29
|
+
const earliest = earliestReportDay == null ? toDay : assertUtcDay(earliestReportDay)
|
|
30
|
+
return {
|
|
31
|
+
from: earliest <= toDay ? earliest : toDay,
|
|
32
|
+
to: toDay,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const from = new Date(to)
|
|
37
|
+
from.setUTCDate(from.getUTCDate() - (period - 1))
|
|
38
|
+
return { from: formatUtcDay(from), to: formatUtcDay(to) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatUtcDay(value: Date): string {
|
|
42
|
+
const year = value.getUTCFullYear().toString().padStart(4, '0')
|
|
43
|
+
const month = (value.getUTCMonth() + 1).toString().padStart(2, '0')
|
|
44
|
+
const day = value.getUTCDate().toString().padStart(2, '0')
|
|
45
|
+
return `${year}-${month}-${day}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertUtcDay(value: string): string {
|
|
49
|
+
if (!UTC_DAY_PATTERN.test(value)) throw new Error('analytics report day must use YYYY-MM-DD')
|
|
50
|
+
const date = new Date(`${value}T00:00:00.000Z`)
|
|
51
|
+
if (!Number.isFinite(date.valueOf()) || formatUtcDay(date) !== value) {
|
|
52
|
+
throw new Error('analytics report day must be a real UTC calendar day')
|
|
53
|
+
}
|
|
54
|
+
return value
|
|
55
|
+
}
|