@byline/host-tanstack-start 4.15.0 → 4.17.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 (183) 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 +17 -5
  3. package/dist/admin-shell/chrome/dashboard.js +41 -5
  4. package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
  5. package/dist/admin-shell/collections/api.d.ts +2 -2
  6. package/dist/admin-shell/collections/create.d.ts +2 -2
  7. package/dist/admin-shell/collections/create.js +1 -0
  8. package/dist/admin-shell/collections/edit.d.ts +2 -2
  9. package/dist/admin-shell/collections/edit.js +1 -0
  10. package/dist/admin-shell/collections/history.d.ts +4 -12
  11. package/dist/admin-shell/collections/history.js +112 -303
  12. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  13. package/dist/admin-shell/collections/reindex-button.js +5 -1
  14. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  15. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  16. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  17. package/dist/admin-shell/collections/version-history.js +259 -0
  18. package/dist/admin-shell/singletons/history.d.ts +19 -0
  19. package/dist/admin-shell/singletons/history.js +170 -0
  20. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  21. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  22. package/dist/admin-shell/singletons/preview-link.js +54 -0
  23. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  24. package/dist/admin-shell/singletons/view-menu.js +116 -0
  25. package/dist/admin-shell/singletons/view.d.ts +18 -0
  26. package/dist/admin-shell/singletons/view.js +259 -0
  27. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  28. package/dist/integrations/analytics-agent.d.ts +20 -0
  29. package/dist/integrations/analytics-agent.js +57 -0
  30. package/dist/integrations/analytics-events.d.ts +51 -0
  31. package/dist/integrations/analytics-events.js +129 -0
  32. package/dist/integrations/api-utils.d.ts +18 -4
  33. package/dist/integrations/api-utils.js +10 -3
  34. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  35. package/dist/integrations/serve-analytics-agent.js +11 -0
  36. package/dist/routes/analytics-range.d.ts +12 -0
  37. package/dist/routes/analytics-range.js +35 -0
  38. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  39. package/dist/routes/create-admin-analytics-route.js +108 -0
  40. package/dist/routes/create-admin-dashboard-route.js +2 -2
  41. package/dist/routes/create-admin-layout-route.js +10 -4
  42. package/dist/routes/create-collection-api-route.js +3 -3
  43. package/dist/routes/create-collection-create-route.js +4 -3
  44. package/dist/routes/create-collection-edit-route.js +4 -3
  45. package/dist/routes/create-collection-history-route.js +4 -3
  46. package/dist/routes/create-collection-list-route.js +4 -3
  47. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  48. package/dist/routes/create-singleton-history-route.js +99 -0
  49. package/dist/routes/create-singleton-route.d.ts +35 -0
  50. package/dist/routes/create-singleton-route.js +130 -0
  51. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  52. package/dist/routes/get-multi-collection-definition.js +6 -0
  53. package/dist/routes/get-singleton-definition.d.ts +10 -0
  54. package/dist/routes/get-singleton-definition.js +6 -0
  55. package/dist/routes/index.d.ts +3 -0
  56. package/dist/routes/index.js +3 -0
  57. package/dist/server-fns/actors.d.ts +16 -0
  58. package/dist/server-fns/actors.js +25 -0
  59. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  60. package/dist/server-fns/admin-document-presentation.js +32 -0
  61. package/dist/server-fns/analytics/index.d.ts +9 -0
  62. package/dist/server-fns/analytics/index.js +2 -0
  63. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  64. package/dist/server-fns/analytics/maintenance.js +42 -0
  65. package/dist/server-fns/analytics/queries.d.ts +25 -0
  66. package/dist/server-fns/analytics/queries.js +54 -0
  67. package/dist/server-fns/collections/actors.d.ts +1 -10
  68. package/dist/server-fns/collections/actors.js +1 -25
  69. package/dist/server-fns/collections/get.js +9 -26
  70. package/dist/server-fns/collections/reorder.js +1 -1
  71. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  72. package/dist/server-fns/collections/upload.js +3 -3
  73. package/dist/server-fns/collections/utils.d.ts +1 -7
  74. package/dist/server-fns/collections/utils.js +1 -4
  75. package/dist/server-fns/serialise.d.ts +14 -0
  76. package/dist/server-fns/serialise.js +4 -0
  77. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  78. package/dist/server-fns/singleton-document-read.js +10 -0
  79. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  80. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  81. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  82. package/dist/server-fns/singletons/change-status.js +7 -0
  83. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  84. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  85. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  86. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  87. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  88. package/dist/server-fns/singletons/find-by-version.js +9 -0
  89. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  90. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  91. package/dist/server-fns/singletons/get.d.ts +24 -0
  92. package/dist/server-fns/singletons/get.js +6 -0
  93. package/dist/server-fns/singletons/history.d.ts +31 -0
  94. package/dist/server-fns/singletons/history.js +22 -0
  95. package/dist/server-fns/singletons/index.d.ts +19 -0
  96. package/dist/server-fns/singletons/index.js +12 -0
  97. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  98. package/dist/server-fns/singletons/restore-version.js +7 -0
  99. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  100. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  101. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  102. package/dist/server-fns/singletons/unpublish.js +7 -0
  103. package/dist/server-fns/singletons/update.d.ts +14 -0
  104. package/dist/server-fns/singletons/update.js +14 -0
  105. package/dist/vite/analytics-dev.d.ts +22 -0
  106. package/dist/vite/analytics-dev.js +28 -0
  107. package/dist/vite/index.d.ts +1 -0
  108. package/dist/vite/index.js +1 -0
  109. package/dist/vitest-jsdom.test.d.ts +8 -0
  110. package/package.json +28 -16
  111. package/src/admin-shell/chrome/admin-menu-drawer.tsx +19 -5
  112. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  113. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  114. package/src/admin-shell/collections/api.tsx +2 -2
  115. package/src/admin-shell/collections/create.tsx +6 -2
  116. package/src/admin-shell/collections/edit.tsx +6 -2
  117. package/src/admin-shell/collections/history.module.css +1 -1
  118. package/src/admin-shell/collections/history.tsx +162 -442
  119. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  120. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  121. package/src/admin-shell/collections/version-history.tsx +349 -0
  122. package/src/admin-shell/singletons/history.test.tsx +407 -0
  123. package/src/admin-shell/singletons/history.tsx +185 -0
  124. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  125. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  126. package/src/admin-shell/singletons/view.test.tsx +400 -0
  127. package/src/admin-shell/singletons/view.tsx +351 -0
  128. package/src/integrations/analytics-agent.tsx +91 -0
  129. package/src/integrations/analytics-events.test.node.ts +245 -0
  130. package/src/integrations/analytics-events.ts +224 -0
  131. package/src/integrations/api-utils.ts +34 -9
  132. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  133. package/src/integrations/serve-analytics-agent.ts +19 -0
  134. package/src/routes/analytics-range.ts +55 -0
  135. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  136. package/src/routes/create-admin-analytics-route.tsx +109 -0
  137. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  138. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  139. package/src/routes/create-admin-layout-route.tsx +15 -8
  140. package/src/routes/create-collection-api-route.tsx +4 -4
  141. package/src/routes/create-collection-create-route.tsx +5 -8
  142. package/src/routes/create-collection-edit-route.tsx +5 -4
  143. package/src/routes/create-collection-history-route.tsx +5 -8
  144. package/src/routes/create-collection-list-route.tsx +5 -8
  145. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  146. package/src/routes/create-singleton-history-route.tsx +116 -0
  147. package/src/routes/create-singleton-route.test.node.ts +172 -0
  148. package/src/routes/create-singleton-route.tsx +176 -0
  149. package/src/routes/get-multi-collection-definition.ts +16 -0
  150. package/src/routes/get-singleton-definition.ts +16 -0
  151. package/src/routes/index.ts +3 -0
  152. package/src/server-fns/actors.ts +43 -0
  153. package/src/server-fns/admin-document-presentation.ts +78 -0
  154. package/src/server-fns/analytics/index.ts +23 -0
  155. package/src/server-fns/analytics/maintenance.ts +54 -0
  156. package/src/server-fns/analytics/queries.ts +86 -0
  157. package/src/server-fns/collections/actors.ts +1 -52
  158. package/src/server-fns/collections/get.ts +10 -49
  159. package/src/server-fns/collections/reorder.ts +1 -1
  160. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  161. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  162. package/src/server-fns/collections/upload.ts +7 -3
  163. package/src/server-fns/collections/utils.ts +1 -9
  164. package/src/server-fns/serialise.ts +17 -0
  165. package/src/server-fns/singleton-document-read.ts +32 -0
  166. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  167. package/src/server-fns/singletons/change-status.ts +19 -0
  168. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  169. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  170. package/src/server-fns/singletons/find-by-version.ts +23 -0
  171. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  172. package/src/server-fns/singletons/get.ts +32 -0
  173. package/src/server-fns/singletons/history.ts +41 -0
  174. package/src/server-fns/singletons/index.ts +20 -0
  175. package/src/server-fns/singletons/restore-version.ts +19 -0
  176. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  177. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  178. package/src/server-fns/singletons/unpublish.ts +19 -0
  179. package/src/server-fns/singletons/update.ts +31 -0
  180. package/src/vite/analytics-dev.test.node.ts +126 -0
  181. package/src/vite/analytics-dev.ts +56 -0
  182. package/src/vite/index.ts +1 -0
  183. package/src/vitest-jsdom.test.tsx +27 -0
@@ -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
+ }
@@ -16,12 +16,16 @@
16
16
  // src/server.ts (TanStack Start server entry point) before any
17
17
  // requests are handled. No need to import it here.
18
18
 
19
- import type { CollectionDefinition, CollectionRecord } from '@byline/core'
20
- import { getCollectionDefinition } from '@byline/core'
19
+ import type {
20
+ CollectionDefinition,
21
+ CollectionRecord,
22
+ MultiCollectionDefinition,
23
+ } from '@byline/core'
24
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
21
25
 
22
26
  import { bylineCore } from './byline-core.js'
23
27
 
24
- export interface EnsuredCollection {
28
+ export interface EnsuredDocumentResource {
25
29
  definition: CollectionDefinition
26
30
  collection: {
27
31
  id: string
@@ -31,21 +35,28 @@ export interface EnsuredCollection {
31
35
  }
32
36
  }
33
37
 
38
+ export interface EnsuredCollection extends EnsuredDocumentResource {
39
+ definition: MultiCollectionDefinition
40
+ }
41
+
34
42
  /**
35
- * Resolve a collection for an admin API request.
43
+ * Resolve either registered document-resource kind against the reconciled
44
+ * collection record.
36
45
  *
37
46
  * Collections are reconciled with the database once at startup by
38
47
  * `initBylineCore()` (see `packages/core/src/services/collection-bootstrap.ts`).
39
48
  * This helper is a per-request cache lookup against the resulting in-memory
40
49
  * registry — no DB I/O, no hash work.
41
50
  *
42
- * Returns `null` when the path is not registered in the client/server config.
51
+ * This broader seam is reserved for operations such as field upload whose core
52
+ * service is explicitly kind-aware; collection route families continue to use
53
+ * `ensureCollection` below.
43
54
  */
44
- export async function ensureCollection(path: string): Promise<EnsuredCollection | null> {
55
+ export async function ensureDocumentResource(
56
+ path: string
57
+ ): Promise<EnsuredDocumentResource | null> {
45
58
  const definition = getCollectionDefinition(path)
46
- if (definition == null) {
47
- return null
48
- }
59
+ if (definition == null) return null
49
60
 
50
61
  let record: CollectionRecord
51
62
  try {
@@ -64,3 +75,17 @@ export async function ensureCollection(path: string): Promise<EnsuredCollection
64
75
  },
65
76
  }
66
77
  }
78
+
79
+ /**
80
+ * Narrow the shared document-resource resolution to a multi-document
81
+ * collection for a collection-scoped admin API request.
82
+ *
83
+ * Returns `null` when the path is unregistered or belongs to a singleton, so
84
+ * collection route families cannot accidentally admit the other resource kind.
85
+ */
86
+ export async function ensureCollection(path: string): Promise<EnsuredCollection | null> {
87
+ const resource = await ensureDocumentResource(path)
88
+ return resource != null && !isSingleton(resource.definition)
89
+ ? { ...resource, definition: resource.definition }
90
+ : null
91
+ }
@@ -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
+ }
@@ -0,0 +1,46 @@
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 { buildAnalyticsDashboardRange } from './analytics-range.js'
12
+
13
+ describe('buildAnalyticsDashboardRange', () => {
14
+ it('builds inclusive UTC periods across month and year boundaries', () => {
15
+ expect(buildAnalyticsDashboardRange(7, new Date('2027-01-03T23:59:59.999Z'))).toEqual({
16
+ from: '2026-12-28',
17
+ to: '2027-01-03',
18
+ })
19
+ })
20
+
21
+ it('builds year-to-date from January 1 in UTC', () => {
22
+ expect(buildAnalyticsDashboardRange('ytd', new Date('2027-06-03T23:59:59.999Z'))).toEqual({
23
+ from: '2027-01-01',
24
+ to: '2027-06-03',
25
+ })
26
+ })
27
+
28
+ it('builds all-time from the earliest reportable day', () => {
29
+ expect(
30
+ buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'), '2024-02-29')
31
+ ).toEqual({ from: '2024-02-29', to: '2027-06-03' })
32
+ })
33
+
34
+ it('uses today for an empty all-time report', () => {
35
+ expect(buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'))).toEqual({
36
+ from: '2027-06-03',
37
+ to: '2027-06-03',
38
+ })
39
+ })
40
+
41
+ it('rejects an invalid earliest report day before constructing a range', () => {
42
+ expect(() =>
43
+ buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'), '2027-02-30')
44
+ ).toThrow(/real UTC calendar day/u)
45
+ })
46
+ })
@@ -0,0 +1,109 @@
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 { createFileRoute, notFound } from '@tanstack/react-router'
10
+
11
+ import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '@byline/admin/analytics'
12
+ import { AnalyticsDashboard } from '@byline/admin/analytics/components/dashboard'
13
+ import { isAnalyticsDashboardPeriod } from '@byline/analytics/config'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { z } from 'zod'
16
+
17
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
18
+ import { useNavigate } from '../admin-shell/chrome/loose-router.js'
19
+ import {
20
+ getAnalyticsCountries,
21
+ getAnalyticsReferrers,
22
+ getAnalyticsReportCoverage,
23
+ getAnalyticsRuntime,
24
+ getAnalyticsSummary,
25
+ getAnalyticsTop,
26
+ } from '../server-fns/analytics/index.js'
27
+ import { getAdminRoutePath } from './admin-path.js'
28
+ import { buildAnalyticsDashboardRange } from './analytics-range.js'
29
+
30
+ const periodSchema = z.preprocess((value) => {
31
+ if (value == null) return undefined
32
+ if (value === 'ytd' || value === 'all') return value
33
+ return Number(value)
34
+ }, z.custom<AnalyticsDashboardPeriod>((value) => isAnalyticsDashboardPeriod(value)).optional())
35
+
36
+ const searchSchema = z.object({
37
+ period: periodSchema.catch(undefined),
38
+ })
39
+
40
+ interface AnalyticsSearch {
41
+ period?: AnalyticsDashboardPeriod
42
+ }
43
+
44
+ export function createAdminAnalyticsRoute(path: string) {
45
+ const Route: any = createFileRoute(path as never)({
46
+ validateSearch: searchSchema,
47
+ loaderDeps: ({ search }: { search: AnalyticsSearch }) => ({
48
+ period: search.period ?? 30,
49
+ }),
50
+ loader: async ({ deps }: { deps: { period: AnalyticsDashboardPeriod } }) => {
51
+ const runtime = await getAnalyticsRuntime()
52
+ if (!runtime.enabled) throw notFound()
53
+
54
+ const coverage = await getAnalyticsReportCoverage()
55
+ const range = buildAnalyticsDashboardRange(deps.period, new Date(), coverage.summaryFrom)
56
+ const [summary, pages, downloads, referrers, countries] = await Promise.all([
57
+ getAnalyticsSummary({ data: range }),
58
+ getAnalyticsTop({ data: { ...range, kind: 'page', limit: 20 } }),
59
+ getAnalyticsTop({ data: { ...range, kind: 'download', limit: 20 } }),
60
+ getAnalyticsReferrers({ data: { ...range, limit: 20 } }),
61
+ getAnalyticsCountries({ data: range }),
62
+ ])
63
+
64
+ return {
65
+ data: {
66
+ summary,
67
+ pages,
68
+ downloads,
69
+ referrers,
70
+ countries,
71
+ range,
72
+ coverage,
73
+ } satisfies AnalyticsDashboardData,
74
+ period: deps.period,
75
+ }
76
+ },
77
+ component: function AdminAnalyticsComponent() {
78
+ const { data, period } = Route.useLoaderData() as {
79
+ data: AnalyticsDashboardData
80
+ period: AnalyticsDashboardPeriod
81
+ }
82
+ const navigate = useNavigate()
83
+ const { t } = useTranslation('byline-admin')
84
+
85
+ return (
86
+ <>
87
+ <BreadcrumbsClient
88
+ breadcrumbs={[
89
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
90
+ { label: t('analytics.title'), href: getAdminRoutePath('analytics') },
91
+ ]}
92
+ />
93
+ <AnalyticsDashboard
94
+ data={data}
95
+ period={period}
96
+ onPeriodChange={(nextPeriod) => {
97
+ navigate({
98
+ to: getAdminRoutePath('analytics'),
99
+ search: { period: nextPeriod },
100
+ })
101
+ }}
102
+ />
103
+ </>
104
+ )
105
+ },
106
+ })
107
+
108
+ return Route
109
+ }
@@ -0,0 +1,70 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ getCollectionStats: vi.fn(),
13
+ collections: [
14
+ { path: 'articles', labels: { singular: 'Article', plural: 'Articles' }, showStats: true },
15
+ {
16
+ singleton: true,
17
+ path: 'site-settings',
18
+ label: 'Site settings',
19
+ // Deliberately collection-like: kind must win even for malformed input.
20
+ showStats: true,
21
+ },
22
+ ],
23
+ }))
24
+
25
+ vi.mock('@tanstack/react-router', () => ({
26
+ createFileRoute: () => (options: Record<string, unknown>) => ({ options }),
27
+ }))
28
+
29
+ vi.mock('@byline/core', () => ({
30
+ filterReadableCollections: (collections: unknown[]) => collections,
31
+ getAdminConfig: () => ({ collections: mocks.collections }),
32
+ isSingleton: (definition: { singleton?: boolean }) => definition.singleton === true,
33
+ }))
34
+
35
+ vi.mock('../server-fns/collections/index.js', () => ({
36
+ getCollectionStats: mocks.getCollectionStats,
37
+ }))
38
+ vi.mock('../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js', () => ({
39
+ BreadcrumbsClient: () => null,
40
+ }))
41
+ vi.mock('../admin-shell/chrome/dashboard.js', () => ({ AdminDashboard: () => null }))
42
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
43
+ vi.mock('./admin-path.js', () => ({ getAdminRoutePath: () => '/admin' }))
44
+
45
+ import { createAdminDashboardRoute } from './create-admin-dashboard-route.js'
46
+
47
+ type DashboardLoader = (input: {
48
+ context: { user: { is_super_admin: boolean; abilities: string[] } }
49
+ }) => Promise<{ statsMap: Record<string, Array<{ status: string; count: number }>> }>
50
+
51
+ describe('admin dashboard route loader', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+ mocks.getCollectionStats.mockResolvedValue([{ status: 'published', count: 4 }])
55
+ })
56
+
57
+ it('never requests collection statistics for a singleton resource', async () => {
58
+ const route = createAdminDashboardRoute('/admin/') as {
59
+ options: { loader: DashboardLoader }
60
+ }
61
+
62
+ await expect(
63
+ route.options.loader({ context: { user: { is_super_admin: true, abilities: [] } } })
64
+ ).resolves.toEqual({ statsMap: { articles: [{ status: 'published', count: 4 }] } })
65
+
66
+ expect(mocks.getCollectionStats).toHaveBeenCalledOnce()
67
+ expect(mocks.getCollectionStats).toHaveBeenCalledWith('articles')
68
+ expect(mocks.getCollectionStats).not.toHaveBeenCalledWith('site-settings')
69
+ })
70
+ })
@@ -8,7 +8,12 @@
8
8
 
9
9
  import { createFileRoute } from '@tanstack/react-router'
10
10
 
11
- import { filterReadableCollections, getAdminConfig } from '@byline/core'
11
+ import {
12
+ filterReadableCollections,
13
+ getAdminConfig,
14
+ isSingleton,
15
+ type MultiCollectionDefinition,
16
+ } from '@byline/core'
12
17
  import { useTranslation } from '@byline/i18n/react'
13
18
 
14
19
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
@@ -40,7 +45,10 @@ export function createAdminDashboardRoute(path: string) {
40
45
 
41
46
  await Promise.all(
42
47
  visible
43
- .filter((c) => c.showStats === true)
48
+ .filter(
49
+ (resource): resource is MultiCollectionDefinition =>
50
+ !isSingleton(resource) && resource.showStats === true
51
+ )
44
52
  .map(async (c) => {
45
53
  try {
46
54
  statsMap[c.path] = await getCollectionStats(c.path)