lytx 0.3.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 (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,1854 @@
1
+ import { SwaggerUI } from "@hono/swagger-ui";
2
+ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
3
+ import type { Context } from "hono";
4
+ import { html, raw } from "hono/html";
5
+ import type { SiteDurableObject } from "../db/durable/siteDurableObject";
6
+
7
+ type Bindings = {
8
+ STORAGE: DurableObjectNamespace<SiteDurableObject>;
9
+ lytx_core_db: D1Database;
10
+ ENVIRONMENT?: string;
11
+ };
12
+
13
+ type ApiKeyPermissions = {
14
+ read?: boolean;
15
+ write?: boolean;
16
+ };
17
+
18
+ type ApiKeyRecord = {
19
+ key: string;
20
+ team_id: number;
21
+ site_id: number | null;
22
+ enabled: number | boolean | null;
23
+ permissions: ApiKeyPermissions | string | null;
24
+ };
25
+
26
+ type SiteRow = {
27
+ site_id: number;
28
+ uuid: string;
29
+ name: string | null;
30
+ team_id: number;
31
+ };
32
+
33
+ type AppEnv = {
34
+ Bindings: Bindings;
35
+ Variables: {
36
+ apiKey: ApiKeyRecord;
37
+ permissions: ApiKeyPermissions;
38
+ };
39
+ };
40
+
41
+ const app = new OpenAPIHono<AppEnv>();
42
+ let apiKeyHasSiteIdColumnCache: boolean | null = null;
43
+
44
+ const MAX_SITE_LIST_LIMIT = 50;
45
+ const MAX_SQL_LIMIT = 500;
46
+ const MAX_QUERY_LIMIT = 500;
47
+ const DEFAULT_QUERY_LIMIT = 100;
48
+ const DEFAULT_WINDOW_SECONDS = 300;
49
+
50
+ const validDateStringSchema = z
51
+ .string()
52
+ .trim()
53
+ .min(1)
54
+ .refine((value) => !Number.isNaN(new Date(value).getTime()), {
55
+ message: "Must be a valid date string",
56
+ });
57
+
58
+ const siteSelectorSchema = z.object({
59
+ site_id: z.coerce.number().int().positive().optional(),
60
+ site_uuid: z.string().trim().min(1).optional(),
61
+ });
62
+
63
+ const siteListQuerySchema = z.object({
64
+ limit: z.coerce.number().int().min(1).max(MAX_SITE_LIST_LIMIT).default(10),
65
+ });
66
+
67
+ const readQuerySchema = siteSelectorSchema.extend({
68
+ windowSeconds: z.coerce.number().int().min(1).max(86400).default(DEFAULT_WINDOW_SECONDS),
69
+ });
70
+
71
+ const dateRangeSchema = z
72
+ .object({
73
+ startDate: validDateStringSchema.optional(),
74
+ endDate: validDateStringSchema.optional(),
75
+ })
76
+ .superRefine((value, ctx) => {
77
+ if (!value.startDate || !value.endDate) return;
78
+ const start = new Date(value.startDate);
79
+ const end = new Date(value.endDate);
80
+ if (end < start) {
81
+ ctx.addIssue({
82
+ code: z.ZodIssueCode.custom,
83
+ path: ["endDate"],
84
+ message: "endDate must be equal to or later than startDate",
85
+ });
86
+ }
87
+ });
88
+
89
+ const eventsQuerySchema = siteSelectorSchema.merge(dateRangeSchema).extend({
90
+ eventType: z.string().trim().min(1).max(255).optional(),
91
+ country: z.string().trim().min(1).max(120).optional(),
92
+ deviceType: z.string().trim().min(1).max(120).optional(),
93
+ referer: z.string().trim().min(1).max(512).optional(),
94
+ limit: z.coerce.number().int().min(1).max(MAX_QUERY_LIMIT).default(DEFAULT_QUERY_LIMIT),
95
+ offset: z.coerce.number().int().min(0).default(0),
96
+ });
97
+
98
+ const statsQuerySchema = siteSelectorSchema.merge(dateRangeSchema);
99
+
100
+ const summaryQuerySchema = siteSelectorSchema.merge(dateRangeSchema).extend({
101
+ search: z.string().trim().min(1).max(255).optional(),
102
+ limit: z.coerce.number().int().min(1).max(MAX_QUERY_LIMIT).default(50),
103
+ offset: z.coerce.number().int().min(0).default(0),
104
+ });
105
+
106
+ const timeSeriesQuerySchema = siteSelectorSchema.merge(dateRangeSchema).extend({
107
+ granularity: z.enum(["hour", "day", "week", "month"]).default("day"),
108
+ byEvent: z
109
+ .enum(["true", "false", "1", "0"])
110
+ .transform((value) => value === "true" || value === "1")
111
+ .optional(),
112
+ });
113
+
114
+ const metricsQuerySchema = siteSelectorSchema.merge(dateRangeSchema).extend({
115
+ metricType: z.enum(["events", "countries", "devices", "referers", "pages"]),
116
+ limit: z.coerce.number().int().min(1).max(MAX_QUERY_LIMIT).default(10),
117
+ });
118
+
119
+ const sqlQueryBodySchema = siteSelectorSchema.extend({
120
+ query: z.string().trim().min(1),
121
+ limit: z.coerce.number().int().min(1).max(MAX_SQL_LIMIT).optional(),
122
+ });
123
+
124
+ const ErrorSchema = z.object({
125
+ error: z.string(),
126
+ details: z.unknown().optional(),
127
+ });
128
+
129
+ const apiSecurity: Array<Record<string, string[]>> = [
130
+ { ApiKeyHeader: [] },
131
+ { BearerAuth: [] },
132
+ ];
133
+
134
+ function extractApiKey(request: Request, allowQueryParam: boolean): string | null {
135
+ const xApiKey = request.headers.get("x-api-key")?.trim();
136
+ if (xApiKey) return xApiKey;
137
+
138
+ const auth = request.headers.get("authorization")?.trim() ?? "";
139
+ if (auth.toLowerCase().startsWith("bearer ")) {
140
+ const token = auth.slice(7).trim();
141
+ if (token) return token;
142
+ }
143
+
144
+ if (allowQueryParam) {
145
+ const url = new URL(request.url);
146
+ const queryToken = url.searchParams.get("api_key")?.trim();
147
+ if (queryToken) return queryToken;
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ function normalizePermissions(raw: ApiKeyRecord["permissions"]): ApiKeyPermissions {
154
+ if (!raw) return { read: true, write: true };
155
+ if (typeof raw === "string") {
156
+ try {
157
+ const parsed = JSON.parse(raw) as ApiKeyPermissions;
158
+ return { read: parsed.read !== false, write: parsed.write !== false };
159
+ } catch {
160
+ return { read: true, write: true };
161
+ }
162
+ }
163
+
164
+ return {
165
+ read: raw.read !== false,
166
+ write: raw.write !== false,
167
+ };
168
+ }
169
+
170
+ function dateFromInput(value?: string): Date | undefined {
171
+ if (!value) return undefined;
172
+ return new Date(value);
173
+ }
174
+
175
+ function jsonError(_c: Context<AppEnv>, status: number, error: string, details?: unknown) {
176
+ const payload = details !== undefined
177
+ ? { error, details }
178
+ : { error };
179
+
180
+ return new Response(JSON.stringify(payload), {
181
+ status,
182
+ headers: {
183
+ "Content-Type": "application/json",
184
+ },
185
+ });
186
+ }
187
+
188
+ function getQueryObject(c: Context<AppEnv>) {
189
+ return Object.fromEntries(new URL(c.req.url).searchParams.entries());
190
+ }
191
+
192
+ function parseQuery<T extends z.ZodTypeAny>(
193
+ c: Context<AppEnv>,
194
+ schema: T,
195
+ ): { data: z.infer<T>; response: null } | { data: null; response: Response } {
196
+ const parsed = schema.safeParse(getQueryObject(c));
197
+ if (!parsed.success) {
198
+ return {
199
+ data: null,
200
+ response: jsonError(c, 400, "Invalid query parameters", parsed.error.flatten()),
201
+ };
202
+ }
203
+ return { data: parsed.data, response: null };
204
+ }
205
+
206
+ async function parseJsonBody<T extends z.ZodTypeAny>(
207
+ c: Context<AppEnv>,
208
+ schema: T,
209
+ ): Promise<{ data: z.infer<T>; response: null } | { data: null; response: Response }> {
210
+ let rawBody: unknown;
211
+ try {
212
+ rawBody = await c.req.json();
213
+ } catch {
214
+ return {
215
+ data: null,
216
+ response: jsonError(c, 400, "Invalid JSON body"),
217
+ };
218
+ }
219
+
220
+ const parsed = schema.safeParse(rawBody);
221
+ if (!parsed.success) {
222
+ return {
223
+ data: null,
224
+ response: jsonError(c, 400, "Invalid request body", parsed.error.flatten()),
225
+ };
226
+ }
227
+ return { data: parsed.data, response: null };
228
+ }
229
+
230
+ async function hasApiKeySiteIdColumn(db: D1Database): Promise<boolean> {
231
+ if (apiKeyHasSiteIdColumnCache !== null) {
232
+ return apiKeyHasSiteIdColumnCache;
233
+ }
234
+
235
+ try {
236
+ const columns = await db
237
+ .prepare("PRAGMA table_info(api_key)")
238
+ .all<{ name: string }>();
239
+ apiKeyHasSiteIdColumnCache = columns.results.some(
240
+ (column) => column.name === "site_id",
241
+ );
242
+ } catch {
243
+ apiKeyHasSiteIdColumnCache = false;
244
+ }
245
+
246
+ return apiKeyHasSiteIdColumnCache;
247
+ }
248
+
249
+ async function loadApiKeyRecord(
250
+ db: D1Database,
251
+ providedKey: string,
252
+ ): Promise<ApiKeyRecord | null> {
253
+ const hasSiteId = await hasApiKeySiteIdColumn(db);
254
+
255
+ if (hasSiteId) {
256
+ return db
257
+ .prepare(
258
+ "SELECT key, team_id, site_id, enabled, permissions FROM api_key WHERE key = ?1 LIMIT 1",
259
+ )
260
+ .bind(providedKey)
261
+ .first<ApiKeyRecord>();
262
+ }
263
+
264
+ const legacyRecord = await db
265
+ .prepare(
266
+ "SELECT key, team_id, enabled, permissions FROM api_key WHERE key = ?1 LIMIT 1",
267
+ )
268
+ .bind(providedKey)
269
+ .first<Omit<ApiKeyRecord, "site_id">>();
270
+
271
+ if (!legacyRecord) {
272
+ return null;
273
+ }
274
+
275
+ return {
276
+ ...legacyRecord,
277
+ site_id: null,
278
+ };
279
+ }
280
+
281
+ async function resolveSiteAndStub(
282
+ c: Context<AppEnv>,
283
+ selection: {
284
+ site_id?: number;
285
+ site_uuid?: string;
286
+ },
287
+ ): Promise<
288
+ | {
289
+ site: SiteRow;
290
+ stub: DurableObjectStub<SiteDurableObject>;
291
+ response: null;
292
+ }
293
+ | {
294
+ site: null;
295
+ stub: null;
296
+ response: Response;
297
+ }
298
+ > {
299
+ const keyRecord = c.get("apiKey");
300
+ const requestedSiteId = selection.site_id;
301
+ const resolvedSiteId = requestedSiteId ?? keyRecord.site_id ?? undefined;
302
+
303
+ if (!resolvedSiteId) {
304
+ return {
305
+ site: null,
306
+ stub: null,
307
+ response: jsonError(
308
+ c,
309
+ 400,
310
+ "site_id is required unless this API key is restricted to a single site",
311
+ ),
312
+ };
313
+ }
314
+
315
+ if (keyRecord.site_id !== null && keyRecord.site_id !== resolvedSiteId) {
316
+ return {
317
+ site: null,
318
+ stub: null,
319
+ response: jsonError(c, 403, `API key is limited to site_id=${keyRecord.site_id}`),
320
+ };
321
+ }
322
+
323
+ const row = selection.site_uuid
324
+ ? await c.env.lytx_core_db
325
+ .prepare(
326
+ "SELECT site_id, uuid, name, team_id FROM sites WHERE site_id = ?1 AND uuid = ?2 LIMIT 1",
327
+ )
328
+ .bind(resolvedSiteId, selection.site_uuid)
329
+ .first<SiteRow>()
330
+ : await c.env.lytx_core_db
331
+ .prepare(
332
+ "SELECT site_id, uuid, name, team_id FROM sites WHERE site_id = ?1 LIMIT 1",
333
+ )
334
+ .bind(resolvedSiteId)
335
+ .first<SiteRow>();
336
+
337
+ if (!row?.uuid) {
338
+ return {
339
+ site: null,
340
+ stub: null,
341
+ response: jsonError(
342
+ c,
343
+ 404,
344
+ selection.site_uuid
345
+ ? `No site found for site_id=${resolvedSiteId} and provided site_uuid`
346
+ : `No site found for site_id=${resolvedSiteId}`,
347
+ ),
348
+ };
349
+ }
350
+
351
+ if (row.team_id !== keyRecord.team_id) {
352
+ return {
353
+ site: null,
354
+ stub: null,
355
+ response: jsonError(
356
+ c,
357
+ 403,
358
+ `site_id=${resolvedSiteId} does not belong to this API key's team`,
359
+ ),
360
+ };
361
+ }
362
+
363
+ const doId = c.env.STORAGE.idFromName(row.uuid);
364
+ const stub = c.env.STORAGE.get(doId);
365
+ await stub.setSiteInfo(row.site_id, row.uuid);
366
+
367
+ return {
368
+ site: row,
369
+ stub,
370
+ response: null,
371
+ };
372
+ }
373
+
374
+ app.doc31("/openapi.json", () => ({
375
+ openapi: "3.1.0",
376
+ info: {
377
+ title: "Lytx Site Data API",
378
+ version: "1.0.0",
379
+ description:
380
+ "API-key protected access to site durable object data. Supports read-only SQL and structured analytics queries with date ranges.",
381
+ },
382
+ components: {
383
+ securitySchemes: {
384
+ ApiKeyHeader: {
385
+ type: "apiKey",
386
+ in: "header",
387
+ name: "x-api-key",
388
+ description: "Primary API key header.",
389
+ },
390
+ BearerAuth: {
391
+ type: "http",
392
+ scheme: "bearer",
393
+ bearerFormat: "API key",
394
+ description: "Bearer token alternative to x-api-key.",
395
+ },
396
+ },
397
+ },
398
+ security: apiSecurity,
399
+ }));
400
+
401
+ function lytxSwaggerPage(c: Context<AppEnv>) {
402
+ const swaggerHtml = SwaggerUI({
403
+ url: "/openapi.json",
404
+ persistAuthorization: true,
405
+ deepLinking: true,
406
+ manuallySwaggerUIHtml: (asset) => `
407
+ <div id="swagger-ui"></div>
408
+ ${asset.css.map((url) => `<link rel="stylesheet" href="${url}" />`).join("\n")}
409
+ ${asset.js.map((url) => `<script src="${url}" crossorigin="anonymous"></script>`).join("\n")}
410
+ <script>
411
+ window.onload = () => {
412
+ window.ui = SwaggerUIBundle({
413
+ dom_id: '#swagger-ui',
414
+ url: '/openapi.json',
415
+ persistAuthorization: true,
416
+ deepLinking: true,
417
+ defaultModelsExpandDepth: 1,
418
+ docExpansion: 'list',
419
+ syntaxHighlight: { activated: true, theme: 'monokai' },
420
+ filter: true,
421
+ })
422
+ }
423
+ </script>
424
+ `,
425
+ });
426
+
427
+ return c.html(html`<!doctype html>
428
+ <html lang="en" data-theme="dark">
429
+ <head>
430
+ <meta charset="utf-8" />
431
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
432
+ <meta name="description" content="Lytx Site Data API Documentation" />
433
+ <title>Lytx API Docs</title>
434
+ <link rel="icon" href="/favicon.ico" />
435
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
436
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
437
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@600;700&display=swap" rel="stylesheet" />
438
+ <style>
439
+ /* ── Lytx Theme Variables ── */
440
+ :root {
441
+ --lytx-bg-primary: #121212;
442
+ --lytx-bg-secondary: #171717;
443
+ --lytx-bg-tertiary: #222222;
444
+ --lytx-text-primary: #f8fafc;
445
+ --lytx-text-secondary: #cbd5e1;
446
+ --lytx-text-tertiary: #94a3b8;
447
+ --lytx-border-primary: #1f2937;
448
+ --lytx-border-secondary: #f59e0b;
449
+ --lytx-card-bg: #171717;
450
+ --lytx-card-border: #1f2937;
451
+ --lytx-input-bg: #141414;
452
+ --lytx-input-border: #334155;
453
+ --lytx-button-bg: #f97316;
454
+ --lytx-button-hover: #ea580c;
455
+ --lytx-color-primary: #f97316;
456
+ --lytx-color-accent: #f59e0b;
457
+ --lytx-color-danger: #ef4444;
458
+ --lytx-color-success: #22c55e;
459
+ --lytx-color-info: #3b82f6;
460
+ }
461
+
462
+ html[data-theme="light"] {
463
+ --lytx-bg-primary: #ffffff;
464
+ --lytx-bg-secondary: #f9fafb;
465
+ --lytx-bg-tertiary: #f3f4f6;
466
+ --lytx-text-primary: #111827;
467
+ --lytx-text-secondary: #4b5563;
468
+ --lytx-text-tertiary: #6b7280;
469
+ --lytx-border-primary: #e5e7eb;
470
+ --lytx-border-secondary: #d1d5db;
471
+ --lytx-card-bg: #ffffff;
472
+ --lytx-card-border: #e5e7eb;
473
+ --lytx-input-bg: #ffffff;
474
+ --lytx-input-border: #d1d5db;
475
+ }
476
+
477
+ /* ── Base Reset ── */
478
+ *, *::before, *::after { box-sizing: border-box; }
479
+
480
+ body {
481
+ margin: 0;
482
+ padding: 0;
483
+ background: var(--lytx-bg-primary);
484
+ color: var(--lytx-text-primary);
485
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
486
+ -webkit-font-smoothing: antialiased;
487
+ -moz-osx-font-smoothing: grayscale;
488
+ }
489
+
490
+ /* ── Custom Top Bar ── */
491
+ .lytx-topbar {
492
+ position: sticky;
493
+ top: 0;
494
+ z-index: 100;
495
+ display: flex;
496
+ align-items: center;
497
+ justify-content: space-between;
498
+ padding: 12px 24px;
499
+ background: var(--lytx-bg-secondary);
500
+ border-bottom: 1px solid var(--lytx-border-primary);
501
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
502
+ }
503
+
504
+ .lytx-topbar-left {
505
+ display: flex;
506
+ align-items: center;
507
+ gap: 12px;
508
+ }
509
+
510
+ .lytx-logo {
511
+ width: 28px;
512
+ height: 28px;
513
+ border-radius: 6px;
514
+ }
515
+
516
+ .lytx-title {
517
+ font-family: 'Montserrat', 'Inter', system-ui, sans-serif;
518
+ font-size: 20px;
519
+ font-weight: 700;
520
+ color: var(--lytx-text-primary);
521
+ text-decoration: none;
522
+ }
523
+
524
+ .lytx-title:hover {
525
+ color: var(--lytx-color-primary);
526
+ transition: color 0.15s ease;
527
+ }
528
+
529
+ .lytx-badge {
530
+ display: inline-flex;
531
+ align-items: center;
532
+ padding: 3px 10px;
533
+ border-radius: 9999px;
534
+ font-size: 11px;
535
+ font-weight: 600;
536
+ letter-spacing: 0.05em;
537
+ text-transform: uppercase;
538
+ background: rgba(249, 115, 22, 0.12);
539
+ color: var(--lytx-color-primary);
540
+ border: 1px solid rgba(249, 115, 22, 0.25);
541
+ }
542
+
543
+ .lytx-topbar-right {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 10px;
547
+ }
548
+
549
+ .lytx-theme-toggle {
550
+ background: transparent;
551
+ border: 1px solid var(--lytx-border-primary);
552
+ border-radius: 8px;
553
+ padding: 6px 10px;
554
+ cursor: pointer;
555
+ color: var(--lytx-text-secondary);
556
+ font-size: 16px;
557
+ transition: all 0.15s ease;
558
+ }
559
+
560
+ .lytx-theme-toggle:hover {
561
+ background: var(--lytx-bg-tertiary);
562
+ color: var(--lytx-text-primary);
563
+ border-color: var(--lytx-text-tertiary);
564
+ }
565
+
566
+ .lytx-link-btn {
567
+ display: inline-flex;
568
+ align-items: center;
569
+ gap: 6px;
570
+ padding: 7px 16px;
571
+ border-radius: 8px;
572
+ font-size: 13px;
573
+ font-weight: 500;
574
+ text-decoration: none;
575
+ transition: all 0.15s ease;
576
+ background: var(--lytx-color-primary);
577
+ color: #fff;
578
+ border: none;
579
+ }
580
+
581
+ .lytx-link-btn:hover {
582
+ background: var(--lytx-button-hover);
583
+ }
584
+
585
+ .lytx-link-btn.outline {
586
+ background: transparent;
587
+ color: var(--lytx-text-secondary);
588
+ border: 1px solid var(--lytx-border-primary);
589
+ }
590
+
591
+ .lytx-link-btn.outline:hover {
592
+ background: var(--lytx-bg-tertiary);
593
+ color: var(--lytx-text-primary);
594
+ }
595
+
596
+ /* ── Swagger UI Container ── */
597
+ .swagger-wrapper {
598
+ max-width: 1400px;
599
+ margin: 0 auto;
600
+ padding: 16px 24px 48px;
601
+ }
602
+
603
+ /* ── Override Swagger UI default theme ── */
604
+
605
+ /* Hide default topbar */
606
+ .swagger-ui .topbar { display: none !important; }
607
+
608
+ /* Base wrapper */
609
+ .swagger-ui { color: var(--lytx-text-primary); }
610
+ .swagger-ui .wrapper { padding: 0; max-width: none; }
611
+
612
+ /* Info section */
613
+ .swagger-ui .info {
614
+ margin: 24px 0 16px;
615
+ padding: 24px;
616
+ background: var(--lytx-card-bg);
617
+ border: 1px solid var(--lytx-card-border);
618
+ border-radius: 12px;
619
+ }
620
+
621
+ .swagger-ui .info hgroup.main {
622
+ margin: 0;
623
+ }
624
+
625
+ .swagger-ui .info .title {
626
+ font-family: 'Montserrat', 'Inter', system-ui, sans-serif;
627
+ color: var(--lytx-text-primary);
628
+ font-size: 28px;
629
+ font-weight: 700;
630
+ }
631
+
632
+ .swagger-ui .info .title small { display: none; }
633
+
634
+ .swagger-ui .info .title small.version-stamp {
635
+ display: inline-flex;
636
+ background: rgba(249, 115, 22, 0.12);
637
+ color: var(--lytx-color-primary);
638
+ border: 1px solid rgba(249, 115, 22, 0.25);
639
+ border-radius: 9999px;
640
+ padding: 2px 10px;
641
+ font-size: 11px;
642
+ font-weight: 600;
643
+ letter-spacing: 0.05em;
644
+ vertical-align: middle;
645
+ margin-left: 8px;
646
+ position: relative;
647
+ top: -2px;
648
+ }
649
+
650
+ .swagger-ui .info .description,
651
+ .swagger-ui .info .description p {
652
+ color: var(--lytx-text-secondary);
653
+ font-size: 14px;
654
+ line-height: 1.6;
655
+ }
656
+
657
+ .swagger-ui .info a {
658
+ color: var(--lytx-color-primary);
659
+ }
660
+
661
+ .swagger-ui .info a:hover {
662
+ color: var(--lytx-button-hover);
663
+ }
664
+
665
+ /* Filter input */
666
+ .swagger-ui .filter-container {
667
+ margin: 0 0 8px;
668
+ padding: 0;
669
+ }
670
+
671
+ .swagger-ui .filter-container .operation-filter-input {
672
+ background: var(--lytx-input-bg);
673
+ border: 1px solid var(--lytx-input-border);
674
+ border-radius: 8px;
675
+ color: var(--lytx-text-primary);
676
+ padding: 10px 16px;
677
+ font-size: 14px;
678
+ margin: 0;
679
+ }
680
+
681
+ .swagger-ui .filter-container .operation-filter-input:focus {
682
+ outline: none;
683
+ border-color: var(--lytx-color-primary);
684
+ box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.15);
685
+ }
686
+
687
+ .swagger-ui .filter-container .operation-filter-input::placeholder {
688
+ color: var(--lytx-text-tertiary);
689
+ }
690
+
691
+ /* Scheme container */
692
+ .swagger-ui .scheme-container {
693
+ background: var(--lytx-card-bg);
694
+ border: 1px solid var(--lytx-card-border);
695
+ border-radius: 12px;
696
+ padding: 16px 20px;
697
+ margin: 0 0 16px;
698
+ box-shadow: none;
699
+ }
700
+
701
+ /* Authorize button */
702
+ .swagger-ui .btn.authorize {
703
+ background: transparent;
704
+ color: var(--lytx-color-primary);
705
+ border: 1px solid var(--lytx-color-primary);
706
+ border-radius: 8px;
707
+ font-weight: 600;
708
+ font-size: 13px;
709
+ padding: 8px 20px;
710
+ transition: all 0.15s ease;
711
+ }
712
+
713
+ .swagger-ui .btn.authorize:hover {
714
+ background: rgba(249, 115, 22, 0.1);
715
+ }
716
+
717
+ .swagger-ui .btn.authorize svg {
718
+ fill: var(--lytx-color-primary);
719
+ }
720
+
721
+ /* Tag groups (sections) */
722
+ .swagger-ui .opblock-tag-section {
723
+ margin-bottom: 8px;
724
+ }
725
+
726
+ .swagger-ui .opblock-tag {
727
+ font-family: 'Montserrat', 'Inter', system-ui, sans-serif;
728
+ color: var(--lytx-text-primary);
729
+ font-size: 18px;
730
+ font-weight: 600;
731
+ border-bottom: 1px solid var(--lytx-border-primary);
732
+ padding: 14px 0;
733
+ margin: 0;
734
+ }
735
+
736
+ .swagger-ui .opblock-tag:hover {
737
+ background: transparent;
738
+ }
739
+
740
+ .swagger-ui .opblock-tag small {
741
+ color: var(--lytx-text-tertiary);
742
+ font-family: 'Inter', system-ui, sans-serif;
743
+ font-size: 13px;
744
+ font-weight: 400;
745
+ }
746
+
747
+ .swagger-ui .opblock-tag svg { fill: var(--lytx-text-tertiary); }
748
+
749
+ /* Operation blocks */
750
+ .swagger-ui .opblock {
751
+ border-radius: 10px;
752
+ border: 1px solid var(--lytx-card-border);
753
+ margin: 8px 0;
754
+ box-shadow: none;
755
+ overflow: hidden;
756
+ }
757
+
758
+ /* GET */
759
+ .swagger-ui .opblock.opblock-get {
760
+ background: rgba(59, 130, 246, 0.04);
761
+ border-color: rgba(59, 130, 246, 0.25);
762
+ }
763
+ .swagger-ui .opblock.opblock-get .opblock-summary-method {
764
+ background: #3b82f6;
765
+ border-radius: 6px;
766
+ font-weight: 700;
767
+ font-size: 12px;
768
+ min-width: 60px;
769
+ text-align: center;
770
+ padding: 6px 12px;
771
+ }
772
+ .swagger-ui .opblock.opblock-get .opblock-summary {
773
+ border-color: rgba(59, 130, 246, 0.15);
774
+ }
775
+
776
+ /* POST */
777
+ .swagger-ui .opblock.opblock-post {
778
+ background: rgba(34, 197, 94, 0.04);
779
+ border-color: rgba(34, 197, 94, 0.25);
780
+ }
781
+ .swagger-ui .opblock.opblock-post .opblock-summary-method {
782
+ background: #22c55e;
783
+ border-radius: 6px;
784
+ font-weight: 700;
785
+ font-size: 12px;
786
+ min-width: 60px;
787
+ text-align: center;
788
+ padding: 6px 12px;
789
+ }
790
+ .swagger-ui .opblock.opblock-post .opblock-summary {
791
+ border-color: rgba(34, 197, 94, 0.15);
792
+ }
793
+
794
+ /* PUT */
795
+ .swagger-ui .opblock.opblock-put {
796
+ background: rgba(249, 115, 22, 0.04);
797
+ border-color: rgba(249, 115, 22, 0.25);
798
+ }
799
+ .swagger-ui .opblock.opblock-put .opblock-summary-method {
800
+ background: var(--lytx-color-primary);
801
+ border-radius: 6px;
802
+ font-weight: 700;
803
+ font-size: 12px;
804
+ min-width: 60px;
805
+ text-align: center;
806
+ padding: 6px 12px;
807
+ }
808
+ .swagger-ui .opblock.opblock-put .opblock-summary {
809
+ border-color: rgba(249, 115, 22, 0.15);
810
+ }
811
+
812
+ /* DELETE */
813
+ .swagger-ui .opblock.opblock-delete {
814
+ background: rgba(239, 68, 68, 0.04);
815
+ border-color: rgba(239, 68, 68, 0.25);
816
+ }
817
+ .swagger-ui .opblock.opblock-delete .opblock-summary-method {
818
+ background: var(--lytx-color-danger);
819
+ border-radius: 6px;
820
+ font-weight: 700;
821
+ font-size: 12px;
822
+ min-width: 60px;
823
+ text-align: center;
824
+ padding: 6px 12px;
825
+ }
826
+ .swagger-ui .opblock.opblock-delete .opblock-summary {
827
+ border-color: rgba(239, 68, 68, 0.15);
828
+ }
829
+
830
+ /* Operation summary row */
831
+ .swagger-ui .opblock .opblock-summary {
832
+ padding: 8px 16px;
833
+ }
834
+
835
+ .swagger-ui .opblock .opblock-summary-path {
836
+ color: var(--lytx-text-primary);
837
+ font-size: 14px;
838
+ font-weight: 500;
839
+ font-family: 'Inter', ui-monospace, SFMono-Regular, Menlo, monospace;
840
+ }
841
+
842
+ .swagger-ui .opblock .opblock-summary-path__deprecated {
843
+ color: var(--lytx-text-tertiary);
844
+ text-decoration: line-through;
845
+ }
846
+
847
+ .swagger-ui .opblock .opblock-summary-description {
848
+ color: var(--lytx-text-secondary);
849
+ font-size: 13px;
850
+ }
851
+
852
+ /* Operation body (expanded) */
853
+ .swagger-ui .opblock-body {
854
+ background: var(--lytx-card-bg);
855
+ }
856
+
857
+ .swagger-ui .opblock-body pre,
858
+ .swagger-ui .opblock-body pre.microlight {
859
+ background: var(--lytx-bg-primary) !important;
860
+ border: 1px solid var(--lytx-border-primary);
861
+ border-radius: 8px;
862
+ color: var(--lytx-text-primary);
863
+ padding: 16px;
864
+ font-size: 13px;
865
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
866
+ }
867
+
868
+ .swagger-ui .opblock-description-wrapper,
869
+ .swagger-ui .opblock-external-docs-wrapper {
870
+ color: var(--lytx-text-secondary);
871
+ padding: 16px 20px;
872
+ }
873
+
874
+ .swagger-ui .opblock-description-wrapper p,
875
+ .swagger-ui .opblock-external-docs-wrapper p {
876
+ color: var(--lytx-text-secondary);
877
+ }
878
+
879
+ /* Parameters table */
880
+ .swagger-ui table thead tr th,
881
+ .swagger-ui table thead tr td {
882
+ color: var(--lytx-text-secondary);
883
+ border-bottom: 1px solid var(--lytx-border-primary);
884
+ font-size: 12px;
885
+ font-weight: 600;
886
+ text-transform: uppercase;
887
+ letter-spacing: 0.05em;
888
+ padding: 10px 12px;
889
+ }
890
+
891
+ .swagger-ui table tbody tr td {
892
+ color: var(--lytx-text-primary);
893
+ border-bottom: 1px solid var(--lytx-border-primary);
894
+ padding: 10px 12px;
895
+ font-size: 13px;
896
+ }
897
+
898
+ .swagger-ui .parameters-col_description p {
899
+ color: var(--lytx-text-secondary);
900
+ font-size: 13px;
901
+ margin: 0;
902
+ }
903
+
904
+ .swagger-ui .parameter__name {
905
+ color: var(--lytx-text-primary);
906
+ font-weight: 600;
907
+ font-size: 13px;
908
+ }
909
+
910
+ .swagger-ui .parameter__name.required::after {
911
+ color: var(--lytx-color-danger);
912
+ }
913
+
914
+ .swagger-ui .parameter__type {
915
+ color: var(--lytx-text-tertiary);
916
+ font-size: 12px;
917
+ }
918
+
919
+ .swagger-ui .parameter__in {
920
+ color: var(--lytx-text-tertiary);
921
+ font-size: 11px;
922
+ }
923
+
924
+ /* Input fields within swagger */
925
+ .swagger-ui input[type=text],
926
+ .swagger-ui input[type=password],
927
+ .swagger-ui input[type=search],
928
+ .swagger-ui input[type=email],
929
+ .swagger-ui input[type=file],
930
+ .swagger-ui textarea,
931
+ .swagger-ui select {
932
+ background: var(--lytx-input-bg);
933
+ border: 1px solid var(--lytx-input-border);
934
+ border-radius: 6px;
935
+ color: var(--lytx-text-primary);
936
+ padding: 8px 12px;
937
+ font-size: 13px;
938
+ font-family: 'Inter', system-ui, sans-serif;
939
+ }
940
+
941
+ .swagger-ui input[type=text]:focus,
942
+ .swagger-ui input[type=password]:focus,
943
+ .swagger-ui textarea:focus,
944
+ .swagger-ui select:focus {
945
+ outline: none;
946
+ border-color: var(--lytx-color-primary);
947
+ box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.15);
948
+ }
949
+
950
+ .swagger-ui select {
951
+ appearance: auto;
952
+ }
953
+
954
+ /* Buttons */
955
+ .swagger-ui .btn {
956
+ border-radius: 6px;
957
+ font-weight: 600;
958
+ font-size: 13px;
959
+ transition: all 0.15s ease;
960
+ box-shadow: none;
961
+ }
962
+
963
+ .swagger-ui .btn.execute {
964
+ background: var(--lytx-color-primary);
965
+ color: #fff;
966
+ border: none;
967
+ border-radius: 8px;
968
+ padding: 8px 24px;
969
+ font-weight: 600;
970
+ }
971
+
972
+ .swagger-ui .btn.execute:hover {
973
+ background: var(--lytx-button-hover);
974
+ }
975
+
976
+ .swagger-ui .btn.cancel {
977
+ color: var(--lytx-text-secondary);
978
+ border-color: var(--lytx-border-primary);
979
+ }
980
+
981
+ .swagger-ui .btn-group .btn {
982
+ border-radius: 6px;
983
+ }
984
+
985
+ /* Response section */
986
+ .swagger-ui .responses-wrapper {
987
+ padding: 0 20px 20px;
988
+ }
989
+
990
+ .swagger-ui .responses-inner {
991
+ padding: 0;
992
+ }
993
+
994
+ .swagger-ui .responses-inner h4,
995
+ .swagger-ui .responses-inner h5,
996
+ .swagger-ui .response-col_status {
997
+ color: var(--lytx-text-primary);
998
+ font-weight: 600;
999
+ }
1000
+
1001
+ .swagger-ui .response-col_description {
1002
+ color: var(--lytx-text-secondary);
1003
+ }
1004
+
1005
+ .swagger-ui .response-col_links {
1006
+ color: var(--lytx-text-tertiary);
1007
+ }
1008
+
1009
+ .swagger-ui .responses-table {
1010
+ padding: 0;
1011
+ }
1012
+
1013
+ /* Tab headers in response */
1014
+ .swagger-ui .tab li {
1015
+ color: var(--lytx-text-secondary);
1016
+ font-size: 13px;
1017
+ }
1018
+
1019
+ .swagger-ui .tab li.active {
1020
+ color: var(--lytx-text-primary);
1021
+ }
1022
+
1023
+ .swagger-ui .tab li button.tablinks {
1024
+ color: inherit;
1025
+ background: transparent;
1026
+ }
1027
+
1028
+ /* Models section */
1029
+ .swagger-ui section.models {
1030
+ border: 1px solid var(--lytx-card-border);
1031
+ border-radius: 12px;
1032
+ background: var(--lytx-card-bg);
1033
+ overflow: hidden;
1034
+ }
1035
+
1036
+ .swagger-ui section.models h4 {
1037
+ color: var(--lytx-text-primary);
1038
+ font-family: 'Montserrat', 'Inter', system-ui, sans-serif;
1039
+ font-weight: 600;
1040
+ font-size: 16px;
1041
+ margin: 0;
1042
+ padding: 16px 20px;
1043
+ border-bottom: 1px solid var(--lytx-border-primary);
1044
+ }
1045
+
1046
+ .swagger-ui section.models .model-container {
1047
+ background: transparent;
1048
+ margin: 4px 0;
1049
+ border-radius: 0;
1050
+ }
1051
+
1052
+ .swagger-ui .model-box {
1053
+ background: transparent;
1054
+ }
1055
+
1056
+ .swagger-ui .model {
1057
+ color: var(--lytx-text-primary);
1058
+ font-size: 13px;
1059
+ }
1060
+
1061
+ .swagger-ui .model .property {
1062
+ color: var(--lytx-text-primary);
1063
+ }
1064
+
1065
+ .swagger-ui .model .property.primitive {
1066
+ color: var(--lytx-text-secondary);
1067
+ }
1068
+
1069
+ .swagger-ui .model-title {
1070
+ color: var(--lytx-text-primary);
1071
+ font-weight: 600;
1072
+ }
1073
+
1074
+ .swagger-ui span.model-title__text {
1075
+ color: var(--lytx-text-primary);
1076
+ font-weight: 600;
1077
+ }
1078
+
1079
+ /* Loading */
1080
+ .swagger-ui .loading-container .loading::after {
1081
+ color: var(--lytx-text-secondary);
1082
+ }
1083
+
1084
+ /* Auth Modal */
1085
+ .swagger-ui .dialog-ux .modal-ux {
1086
+ background: var(--lytx-card-bg);
1087
+ border: 1px solid var(--lytx-card-border);
1088
+ border-radius: 12px;
1089
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
1090
+ }
1091
+
1092
+ .swagger-ui .dialog-ux .modal-ux-header {
1093
+ border-bottom: 1px solid var(--lytx-border-primary);
1094
+ padding: 16px 24px;
1095
+ }
1096
+
1097
+ .swagger-ui .dialog-ux .modal-ux-header h3 {
1098
+ color: var(--lytx-text-primary);
1099
+ font-family: 'Montserrat', 'Inter', system-ui, sans-serif;
1100
+ font-weight: 600;
1101
+ }
1102
+
1103
+ .swagger-ui .dialog-ux .modal-ux-content {
1104
+ padding: 24px;
1105
+ color: var(--lytx-text-secondary);
1106
+ }
1107
+
1108
+ .swagger-ui .dialog-ux .modal-ux-content p {
1109
+ color: var(--lytx-text-secondary);
1110
+ }
1111
+
1112
+ .swagger-ui .dialog-ux .modal-ux-content label {
1113
+ color: var(--lytx-text-primary);
1114
+ }
1115
+
1116
+ .swagger-ui .dialog-ux .backdrop-ux {
1117
+ background: rgba(0, 0, 0, 0.6);
1118
+ }
1119
+
1120
+ .swagger-ui .auth-btn-wrapper .btn-done {
1121
+ background: var(--lytx-color-primary);
1122
+ color: #fff;
1123
+ border: none;
1124
+ border-radius: 8px;
1125
+ }
1126
+
1127
+ /* Copy button */
1128
+ .swagger-ui .copy-to-clipboard { background: var(--lytx-bg-tertiary); }
1129
+
1130
+ /* Servers dropdown */
1131
+ .swagger-ui .servers > label {
1132
+ color: var(--lytx-text-secondary);
1133
+ }
1134
+
1135
+ .swagger-ui .servers > label select {
1136
+ background: var(--lytx-input-bg);
1137
+ border: 1px solid var(--lytx-input-border);
1138
+ color: var(--lytx-text-primary);
1139
+ border-radius: 6px;
1140
+ }
1141
+
1142
+ /* Download URL (if any) */
1143
+ .swagger-ui .download-url-wrapper .download-url-button {
1144
+ background: var(--lytx-color-primary);
1145
+ color: #fff;
1146
+ border-radius: 8px;
1147
+ border: none;
1148
+ }
1149
+
1150
+ /* Arrow / toggle icons */
1151
+ .swagger-ui svg:not(:root) {
1152
+ fill: var(--lytx-text-tertiary);
1153
+ }
1154
+
1155
+ .swagger-ui .expand-operation svg { fill: var(--lytx-text-tertiary); }
1156
+
1157
+ /* Highlighted JSON */
1158
+ .swagger-ui .highlight-code > .microlight {
1159
+ background: var(--lytx-bg-primary) !important;
1160
+ border: 1px solid var(--lytx-border-primary);
1161
+ border-radius: 8px;
1162
+ padding: 16px !important;
1163
+ font-size: 13px;
1164
+ }
1165
+
1166
+ /* Response content type label */
1167
+ .swagger-ui .response-content-type.controls-accept-header select {
1168
+ background: var(--lytx-input-bg);
1169
+ border: 1px solid var(--lytx-input-border);
1170
+ color: var(--lytx-text-primary);
1171
+ border-radius: 6px;
1172
+ }
1173
+
1174
+ /* Try it out button */
1175
+ .swagger-ui .try-out__btn {
1176
+ border-color: var(--lytx-color-primary);
1177
+ color: var(--lytx-color-primary);
1178
+ border-radius: 6px;
1179
+ font-weight: 600;
1180
+ }
1181
+
1182
+ .swagger-ui .try-out__btn:hover {
1183
+ background: rgba(249, 115, 22, 0.08);
1184
+ }
1185
+
1186
+ /* Markdown rendered within descriptions */
1187
+ .swagger-ui .markdown p,
1188
+ .swagger-ui .markdown li,
1189
+ .swagger-ui .renderedMarkdown p {
1190
+ color: var(--lytx-text-secondary);
1191
+ }
1192
+
1193
+ .swagger-ui .markdown code,
1194
+ .swagger-ui .renderedMarkdown code {
1195
+ background: var(--lytx-bg-tertiary);
1196
+ color: var(--lytx-color-primary);
1197
+ padding: 2px 6px;
1198
+ border-radius: 4px;
1199
+ font-size: 12px;
1200
+ }
1201
+
1202
+ /* Override all remaining text */
1203
+ .swagger-ui,
1204
+ .swagger-ui .info .title,
1205
+ .swagger-ui .opblock .opblock-section-header h4,
1206
+ .swagger-ui .opblock .opblock-section-header label,
1207
+ .swagger-ui label,
1208
+ .swagger-ui .model-hint {
1209
+ color: var(--lytx-text-primary);
1210
+ }
1211
+
1212
+ .swagger-ui .opblock .opblock-section-header {
1213
+ background: var(--lytx-bg-secondary);
1214
+ border-bottom: 1px solid var(--lytx-border-primary);
1215
+ box-shadow: none;
1216
+ }
1217
+
1218
+ .swagger-ui .opblock .opblock-section-header h4 {
1219
+ color: var(--lytx-text-primary);
1220
+ font-size: 14px;
1221
+ font-weight: 600;
1222
+ }
1223
+
1224
+ /* Response codes */
1225
+ .swagger-ui .responses-wrapper .response-col_status {
1226
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1227
+ font-size: 13px;
1228
+ font-weight: 700;
1229
+ }
1230
+
1231
+ /* Scrollbar styling for dark mode */
1232
+ .swagger-ui ::-webkit-scrollbar {
1233
+ width: 8px;
1234
+ height: 8px;
1235
+ }
1236
+ .swagger-ui ::-webkit-scrollbar-track {
1237
+ background: var(--lytx-bg-secondary);
1238
+ }
1239
+ .swagger-ui ::-webkit-scrollbar-thumb {
1240
+ background: var(--lytx-bg-tertiary);
1241
+ border-radius: 4px;
1242
+ }
1243
+ .swagger-ui ::-webkit-scrollbar-thumb:hover {
1244
+ background: var(--lytx-text-tertiary);
1245
+ }
1246
+
1247
+ /* Responsive adjustments */
1248
+ @media (max-width: 768px) {
1249
+ .lytx-topbar {
1250
+ padding: 10px 16px;
1251
+ }
1252
+ .lytx-title {
1253
+ font-size: 17px;
1254
+ }
1255
+ .lytx-badge {
1256
+ display: none;
1257
+ }
1258
+ .swagger-wrapper {
1259
+ padding: 12px 16px 32px;
1260
+ }
1261
+ .swagger-ui .info {
1262
+ padding: 16px;
1263
+ }
1264
+ }
1265
+ </style>
1266
+ <script>
1267
+ (function() {
1268
+ var saved = localStorage.getItem('lytx-api-docs-theme');
1269
+ if (saved === 'light') {
1270
+ document.documentElement.setAttribute('data-theme', 'light');
1271
+ }
1272
+ })();
1273
+ </script>
1274
+ </head>
1275
+ <body>
1276
+ <div class="lytx-topbar">
1277
+ <div class="lytx-topbar-left">
1278
+ <img src="/logo.png" alt="Lytx" class="lytx-logo" />
1279
+ <a href="/" class="lytx-title">Lytx</a>
1280
+ <span class="lytx-badge">API Docs</span>
1281
+ </div>
1282
+ <div class="lytx-topbar-right">
1283
+ <button class="lytx-theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle light/dark theme">
1284
+ <span id="theme-icon">&#9790;</span>
1285
+ </button>
1286
+ <a href="/settings" class="lytx-link-btn outline">Dashboard</a>
1287
+ <a href="/" class="lytx-link-btn">Home</a>
1288
+ </div>
1289
+ </div>
1290
+ <div class="swagger-wrapper">
1291
+ ${raw(swaggerHtml)}
1292
+ </div>
1293
+ <script>
1294
+ (function() {
1295
+ var toggle = document.getElementById('theme-toggle');
1296
+ var icon = document.getElementById('theme-icon');
1297
+ function updateIcon() {
1298
+ var theme = document.documentElement.getAttribute('data-theme');
1299
+ icon.textContent = theme === 'light' ? '\\u2600' : '\\u263E';
1300
+ }
1301
+ updateIcon();
1302
+ toggle.addEventListener('click', function() {
1303
+ var current = document.documentElement.getAttribute('data-theme');
1304
+ var next = current === 'light' ? 'dark' : 'light';
1305
+ document.documentElement.setAttribute('data-theme', next);
1306
+ localStorage.setItem('lytx-api-docs-theme', next);
1307
+ updateIcon();
1308
+ });
1309
+ })();
1310
+ <\/script>
1311
+ </body>
1312
+ </html>`);
1313
+ }
1314
+
1315
+ app.get("/", (c) => lytxSwaggerPage(c));
1316
+ app.get("/docs", (c) => lytxSwaggerPage(c));
1317
+
1318
+ const healthRoute = createRoute({
1319
+ method: "get",
1320
+ path: "/health",
1321
+ tags: ["System"],
1322
+ responses: {
1323
+ 200: {
1324
+ description: "Worker health check",
1325
+ content: {
1326
+ "application/json": {
1327
+ schema: z.object({ ok: z.boolean() }),
1328
+ },
1329
+ },
1330
+ },
1331
+ },
1332
+ });
1333
+
1334
+ app.openapi(healthRoute, (c) => c.json({ ok: true }, 200));
1335
+
1336
+ app.use("/do/*", async (c, next) => {
1337
+ const isDevelopment =
1338
+ c.env.ENVIRONMENT === "development" || c.env.ENVIRONMENT === "dev";
1339
+ const providedKey = extractApiKey(c.req.raw, isDevelopment);
1340
+
1341
+ if (!providedKey) {
1342
+ return c.json(
1343
+ {
1344
+ error: isDevelopment
1345
+ ? "Missing API key. Use x-api-key header or ?api_key=... in development."
1346
+ : "Missing API key",
1347
+ },
1348
+ 401,
1349
+ );
1350
+ }
1351
+
1352
+ const keyRecord = await loadApiKeyRecord(c.env.lytx_core_db, providedKey);
1353
+
1354
+ if (!keyRecord) {
1355
+ return c.json({ error: "Invalid API key" }, 401);
1356
+ }
1357
+
1358
+ if (keyRecord.enabled === 0 || keyRecord.enabled === false) {
1359
+ return c.json({ error: "API key disabled" }, 403);
1360
+ }
1361
+
1362
+ const permissions = normalizePermissions(keyRecord.permissions);
1363
+ if (!permissions.read) {
1364
+ return c.json({ error: "API key lacks read permission" }, 403);
1365
+ }
1366
+
1367
+ c.set("apiKey", keyRecord);
1368
+ c.set("permissions", permissions);
1369
+ await next();
1370
+ });
1371
+
1372
+ const listSitesRoute = createRoute({
1373
+ method: "get",
1374
+ path: "/do/sites",
1375
+ tags: ["Sites"],
1376
+ security: apiSecurity,
1377
+ request: {
1378
+ query: siteListQuerySchema,
1379
+ },
1380
+ responses: {
1381
+ 200: { description: "Accessible sites" },
1382
+ 401: {
1383
+ description: "Unauthorized",
1384
+ content: { "application/json": { schema: ErrorSchema } },
1385
+ },
1386
+ },
1387
+ });
1388
+
1389
+ app.openapi(listSitesRoute, async (c) => {
1390
+ const parsed = parseQuery(c, siteListQuerySchema);
1391
+ if (parsed.response) return parsed.response;
1392
+
1393
+ const query = parsed.data;
1394
+ const keyRecord = c.get("apiKey");
1395
+
1396
+ const rows = keyRecord.site_id !== null
1397
+ ? await c.env.lytx_core_db
1398
+ .prepare(
1399
+ "SELECT site_id, uuid, name FROM sites WHERE team_id = ?1 AND site_id = ?2 ORDER BY site_id DESC LIMIT ?3",
1400
+ )
1401
+ .bind(keyRecord.team_id, keyRecord.site_id, query.limit)
1402
+ .all<{ site_id: number; uuid: string; name: string | null }>()
1403
+ : await c.env.lytx_core_db
1404
+ .prepare(
1405
+ "SELECT site_id, uuid, name FROM sites WHERE team_id = ?1 ORDER BY site_id DESC LIMIT ?2",
1406
+ )
1407
+ .bind(keyRecord.team_id, query.limit)
1408
+ .all<{ site_id: number; uuid: string; name: string | null }>();
1409
+
1410
+ return c.json(
1411
+ {
1412
+ count: rows.results.length,
1413
+ sites: rows.results,
1414
+ },
1415
+ 200,
1416
+ );
1417
+ });
1418
+
1419
+ const readRoute = createRoute({
1420
+ method: "get",
1421
+ path: "/do/read",
1422
+ tags: ["Sites"],
1423
+ security: apiSecurity,
1424
+ request: {
1425
+ query: readQuerySchema,
1426
+ },
1427
+ responses: {
1428
+ 200: { description: "Site health and current visitors" },
1429
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1430
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1431
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1432
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1433
+ },
1434
+ });
1435
+
1436
+ app.openapi(readRoute, async (c) => {
1437
+ const parsed = parseQuery(c, readQuerySchema);
1438
+ if (parsed.response) return parsed.response;
1439
+
1440
+ const query = parsed.data;
1441
+ const siteAndStub = await resolveSiteAndStub(c, query);
1442
+ if (siteAndStub.response) return siteAndStub.response;
1443
+
1444
+ try {
1445
+ const [health, currentVisitors] = await Promise.all([
1446
+ siteAndStub.stub.healthCheck(),
1447
+ siteAndStub.stub.getCurrentVisitors({ windowSeconds: query.windowSeconds }),
1448
+ ]);
1449
+
1450
+ return c.json(
1451
+ {
1452
+ site: {
1453
+ site_id: siteAndStub.site.site_id,
1454
+ site_uuid: siteAndStub.site.uuid,
1455
+ name: siteAndStub.site.name,
1456
+ },
1457
+ health,
1458
+ currentVisitors,
1459
+ },
1460
+ 200,
1461
+ );
1462
+ } catch (error) {
1463
+ return jsonError(
1464
+ c,
1465
+ 500,
1466
+ error instanceof Error ? error.message : "Unknown durable object error",
1467
+ );
1468
+ }
1469
+ });
1470
+
1471
+ const schemaRoute = createRoute({
1472
+ method: "get",
1473
+ path: "/do/schema",
1474
+ tags: ["Schema"],
1475
+ security: apiSecurity,
1476
+ request: {
1477
+ query: siteSelectorSchema,
1478
+ },
1479
+ responses: {
1480
+ 200: { description: "Runtime schema metadata" },
1481
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1482
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1483
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1484
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1485
+ },
1486
+ });
1487
+
1488
+ app.openapi(schemaRoute, async (c) => {
1489
+ const parsed = parseQuery(c, siteSelectorSchema);
1490
+ if (parsed.response) return parsed.response;
1491
+
1492
+ const query = parsed.data;
1493
+ const siteAndStub = await resolveSiteAndStub(c, query);
1494
+ if (siteAndStub.response) return siteAndStub.response;
1495
+
1496
+ try {
1497
+ const schemaResult = await siteAndStub.stub.getSchema();
1498
+ if (!schemaResult.success) {
1499
+ return jsonError(c, 500, schemaResult.error ?? "Failed to get schema");
1500
+ }
1501
+
1502
+ return c.json(
1503
+ {
1504
+ site: {
1505
+ site_id: siteAndStub.site.site_id,
1506
+ site_uuid: siteAndStub.site.uuid,
1507
+ name: siteAndStub.site.name,
1508
+ },
1509
+ tables: schemaResult.tables,
1510
+ },
1511
+ 200,
1512
+ );
1513
+ } catch (error) {
1514
+ return jsonError(
1515
+ c,
1516
+ 500,
1517
+ error instanceof Error ? error.message : "Unknown durable object error",
1518
+ );
1519
+ }
1520
+ });
1521
+
1522
+ const eventsRoute = createRoute({
1523
+ method: "get",
1524
+ path: "/do/events",
1525
+ tags: ["Events"],
1526
+ security: apiSecurity,
1527
+ request: {
1528
+ query: eventsQuerySchema,
1529
+ },
1530
+ responses: {
1531
+ 200: { description: "Filtered events" },
1532
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1533
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1534
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1535
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1536
+ },
1537
+ });
1538
+
1539
+ app.openapi(eventsRoute, async (c) => {
1540
+ const parsed = parseQuery(c, eventsQuerySchema);
1541
+ if (parsed.response) return parsed.response;
1542
+
1543
+ const query = parsed.data;
1544
+ const siteAndStub = await resolveSiteAndStub(c, query);
1545
+ if (siteAndStub.response) return siteAndStub.response;
1546
+
1547
+ try {
1548
+ const result = await siteAndStub.stub.getEventsData({
1549
+ startDate: dateFromInput(query.startDate),
1550
+ endDate: dateFromInput(query.endDate),
1551
+ eventType: query.eventType,
1552
+ country: query.country,
1553
+ deviceType: query.deviceType,
1554
+ referer: query.referer,
1555
+ limit: query.limit,
1556
+ offset: query.offset,
1557
+ });
1558
+
1559
+ return c.json(
1560
+ {
1561
+ site: {
1562
+ site_id: siteAndStub.site.site_id,
1563
+ site_uuid: siteAndStub.site.uuid,
1564
+ name: siteAndStub.site.name,
1565
+ },
1566
+ query: result,
1567
+ },
1568
+ 200,
1569
+ );
1570
+ } catch (error) {
1571
+ return jsonError(
1572
+ c,
1573
+ 500,
1574
+ error instanceof Error ? error.message : "Unknown durable object error",
1575
+ );
1576
+ }
1577
+ });
1578
+
1579
+ const statsRoute = createRoute({
1580
+ method: "get",
1581
+ path: "/do/stats",
1582
+ tags: ["Analytics"],
1583
+ security: apiSecurity,
1584
+ request: {
1585
+ query: statsQuerySchema,
1586
+ },
1587
+ responses: {
1588
+ 200: { description: "Aggregated stats" },
1589
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1590
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1591
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1592
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1593
+ },
1594
+ });
1595
+
1596
+ app.openapi(statsRoute, async (c) => {
1597
+ const parsed = parseQuery(c, statsQuerySchema);
1598
+ if (parsed.response) return parsed.response;
1599
+
1600
+ const query = parsed.data;
1601
+ const siteAndStub = await resolveSiteAndStub(c, query);
1602
+ if (siteAndStub.response) return siteAndStub.response;
1603
+
1604
+ try {
1605
+ const stats = await siteAndStub.stub.getStats({
1606
+ startDate: dateFromInput(query.startDate),
1607
+ endDate: dateFromInput(query.endDate),
1608
+ });
1609
+
1610
+ return c.json(
1611
+ {
1612
+ site: {
1613
+ site_id: siteAndStub.site.site_id,
1614
+ site_uuid: siteAndStub.site.uuid,
1615
+ name: siteAndStub.site.name,
1616
+ },
1617
+ stats,
1618
+ },
1619
+ 200,
1620
+ );
1621
+ } catch (error) {
1622
+ return jsonError(
1623
+ c,
1624
+ 500,
1625
+ error instanceof Error ? error.message : "Unknown durable object error",
1626
+ );
1627
+ }
1628
+ });
1629
+
1630
+ const summaryRoute = createRoute({
1631
+ method: "get",
1632
+ path: "/do/event-summary",
1633
+ tags: ["Analytics"],
1634
+ security: apiSecurity,
1635
+ request: {
1636
+ query: summaryQuerySchema,
1637
+ },
1638
+ responses: {
1639
+ 200: { description: "Event summary" },
1640
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1641
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1642
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1643
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1644
+ },
1645
+ });
1646
+
1647
+ app.openapi(summaryRoute, async (c) => {
1648
+ const parsed = parseQuery(c, summaryQuerySchema);
1649
+ if (parsed.response) return parsed.response;
1650
+
1651
+ const query = parsed.data;
1652
+ const siteAndStub = await resolveSiteAndStub(c, query);
1653
+ if (siteAndStub.response) return siteAndStub.response;
1654
+
1655
+ try {
1656
+ const summary = await siteAndStub.stub.getEventSummary({
1657
+ startDate: dateFromInput(query.startDate),
1658
+ endDate: dateFromInput(query.endDate),
1659
+ search: query.search,
1660
+ limit: query.limit,
1661
+ offset: query.offset,
1662
+ });
1663
+
1664
+ return c.json(
1665
+ {
1666
+ site: {
1667
+ site_id: siteAndStub.site.site_id,
1668
+ site_uuid: siteAndStub.site.uuid,
1669
+ name: siteAndStub.site.name,
1670
+ },
1671
+ summary,
1672
+ },
1673
+ 200,
1674
+ );
1675
+ } catch (error) {
1676
+ return jsonError(
1677
+ c,
1678
+ 500,
1679
+ error instanceof Error ? error.message : "Unknown durable object error",
1680
+ );
1681
+ }
1682
+ });
1683
+
1684
+ const timeSeriesRoute = createRoute({
1685
+ method: "get",
1686
+ path: "/do/time-series",
1687
+ tags: ["Analytics"],
1688
+ security: apiSecurity,
1689
+ request: {
1690
+ query: timeSeriesQuerySchema,
1691
+ },
1692
+ responses: {
1693
+ 200: { description: "Time-series data" },
1694
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1695
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1696
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1697
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1698
+ },
1699
+ });
1700
+
1701
+ app.openapi(timeSeriesRoute, async (c) => {
1702
+ const parsed = parseQuery(c, timeSeriesQuerySchema);
1703
+ if (parsed.response) return parsed.response;
1704
+
1705
+ const query = parsed.data;
1706
+ const siteAndStub = await resolveSiteAndStub(c, query);
1707
+ if (siteAndStub.response) return siteAndStub.response;
1708
+
1709
+ try {
1710
+ const timeSeries = await siteAndStub.stub.getTimeSeries({
1711
+ startDate: dateFromInput(query.startDate),
1712
+ endDate: dateFromInput(query.endDate),
1713
+ granularity: query.granularity,
1714
+ byEvent: query.byEvent ?? false,
1715
+ });
1716
+
1717
+ return c.json(
1718
+ {
1719
+ site: {
1720
+ site_id: siteAndStub.site.site_id,
1721
+ site_uuid: siteAndStub.site.uuid,
1722
+ name: siteAndStub.site.name,
1723
+ },
1724
+ timeSeries,
1725
+ },
1726
+ 200,
1727
+ );
1728
+ } catch (error) {
1729
+ return jsonError(
1730
+ c,
1731
+ 500,
1732
+ error instanceof Error ? error.message : "Unknown durable object error",
1733
+ );
1734
+ }
1735
+ });
1736
+
1737
+ const metricsRoute = createRoute({
1738
+ method: "get",
1739
+ path: "/do/metrics",
1740
+ tags: ["Analytics"],
1741
+ security: apiSecurity,
1742
+ request: {
1743
+ query: metricsQuerySchema,
1744
+ },
1745
+ responses: {
1746
+ 200: { description: "Metric breakdown" },
1747
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1748
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1749
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1750
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1751
+ },
1752
+ });
1753
+
1754
+ app.openapi(metricsRoute, async (c) => {
1755
+ const parsed = parseQuery(c, metricsQuerySchema);
1756
+ if (parsed.response) return parsed.response;
1757
+
1758
+ const query = parsed.data;
1759
+ const siteAndStub = await resolveSiteAndStub(c, query);
1760
+ if (siteAndStub.response) return siteAndStub.response;
1761
+
1762
+ try {
1763
+ const metrics = await siteAndStub.stub.getMetrics({
1764
+ startDate: dateFromInput(query.startDate),
1765
+ endDate: dateFromInput(query.endDate),
1766
+ metricType: query.metricType,
1767
+ limit: query.limit,
1768
+ });
1769
+
1770
+ return c.json(
1771
+ {
1772
+ site: {
1773
+ site_id: siteAndStub.site.site_id,
1774
+ site_uuid: siteAndStub.site.uuid,
1775
+ name: siteAndStub.site.name,
1776
+ },
1777
+ metrics,
1778
+ },
1779
+ 200,
1780
+ );
1781
+ } catch (error) {
1782
+ return jsonError(
1783
+ c,
1784
+ 500,
1785
+ error instanceof Error ? error.message : "Unknown durable object error",
1786
+ );
1787
+ }
1788
+ });
1789
+
1790
+ const sqlQueryRoute = createRoute({
1791
+ method: "post",
1792
+ path: "/do/query",
1793
+ tags: ["SQL"],
1794
+ security: apiSecurity,
1795
+ request: {
1796
+ body: {
1797
+ required: true,
1798
+ content: {
1799
+ "application/json": {
1800
+ schema: sqlQueryBodySchema,
1801
+ },
1802
+ },
1803
+ },
1804
+ },
1805
+ responses: {
1806
+ 200: { description: "SQL query results" },
1807
+ 400: { description: "Bad request", content: { "application/json": { schema: ErrorSchema } } },
1808
+ 403: { description: "Forbidden", content: { "application/json": { schema: ErrorSchema } } },
1809
+ 404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
1810
+ 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
1811
+ },
1812
+ });
1813
+
1814
+ app.openapi(sqlQueryRoute, async (c) => {
1815
+ const parsedBody = await parseJsonBody(c, sqlQueryBodySchema);
1816
+ if (parsedBody.response) return parsedBody.response;
1817
+
1818
+ const body = parsedBody.data;
1819
+ const siteAndStub = await resolveSiteAndStub(c, body);
1820
+ if (siteAndStub.response) return siteAndStub.response;
1821
+
1822
+ try {
1823
+ const result = await siteAndStub.stub.runSqlQuery(
1824
+ body.query,
1825
+ body.limit ? { limit: body.limit } : undefined,
1826
+ );
1827
+
1828
+ if (!result.success) {
1829
+ return c.json({ error: result.error ?? "Query failed" }, 400);
1830
+ }
1831
+
1832
+ return c.json(
1833
+ {
1834
+ site: {
1835
+ site_id: siteAndStub.site.site_id,
1836
+ site_uuid: siteAndStub.site.uuid,
1837
+ name: siteAndStub.site.name,
1838
+ },
1839
+ rows: result.rows ?? [],
1840
+ rowCount: result.rowCount ?? 0,
1841
+ limit: result.limit,
1842
+ },
1843
+ 200,
1844
+ );
1845
+ } catch (error) {
1846
+ return jsonError(
1847
+ c,
1848
+ 500,
1849
+ error instanceof Error ? error.message : "Unknown durable object error",
1850
+ );
1851
+ }
1852
+ });
1853
+
1854
+ export default app;