@heyramzi/utils 0.0.0-stage → 1.12.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +103 -4
  4. package/src/ai/image-agnes.test.ts +130 -0
  5. package/src/ai/image.ts +435 -0
  6. package/src/ai/models.test.ts +98 -0
  7. package/src/ai/models.ts +450 -0
  8. package/src/analytics/client.test.ts +149 -0
  9. package/src/analytics/client.ts +129 -0
  10. package/src/analytics/server.test.ts +165 -0
  11. package/src/analytics/server.ts +88 -0
  12. package/src/analytics/svelte.test.ts +105 -0
  13. package/src/analytics/svelte.ts +58 -0
  14. package/src/analytics/types.ts +26 -0
  15. package/src/analytics/umami/protocol.test.ts +18 -0
  16. package/src/analytics/umami/protocol.ts +104 -0
  17. package/src/analytics/umami/reporting.test.ts +391 -0
  18. package/src/analytics/umami/reporting.ts +627 -0
  19. package/src/auth/config/otp.ts +8 -0
  20. package/src/auth/config/rate-limits.ts +11 -0
  21. package/src/auth/config/trusted-origins.ts +32 -0
  22. package/src/auth/email/index.ts +7 -0
  23. package/src/auth/email/layout.test.ts +73 -0
  24. package/src/auth/email/layout.ts +122 -0
  25. package/src/auth/email/resend.test.ts +35 -0
  26. package/src/auth/email/resend.ts +26 -0
  27. package/src/auth/email/template.ts +77 -0
  28. package/src/auth/email/types.ts +74 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/cache.test.ts +67 -0
  31. package/src/cache.ts +5 -0
  32. package/src/cn.test.ts +52 -0
  33. package/src/cn.ts +16 -0
  34. package/src/collection.test.ts +66 -0
  35. package/src/collection.ts +8 -0
  36. package/src/crypto.test.ts +86 -0
  37. package/src/crypto.ts +157 -0
  38. package/src/error.test.ts +79 -0
  39. package/src/error.ts +16 -0
  40. package/src/errors/sentry/index.ts +20 -0
  41. package/src/errors/sentry/protocol.test.ts +85 -0
  42. package/src/errors/sentry/protocol.ts +144 -0
  43. package/src/errors/sentry/sveltekit.test.ts +88 -0
  44. package/src/errors/sentry/sveltekit.ts +188 -0
  45. package/src/format.test.ts +225 -0
  46. package/src/format.ts +103 -0
  47. package/src/grab.d.ts +2 -0
  48. package/src/grab.js +112 -0
  49. package/src/index.ts +38 -0
  50. package/src/logger-types.ts +14 -0
  51. package/src/logger.ts +24 -0
  52. package/src/math.test.ts +41 -0
  53. package/src/math.ts +3 -0
  54. package/src/og/fonts.ts +69 -0
  55. package/src/og/index.ts +5 -0
  56. package/src/og/markup.test.ts +73 -0
  57. package/src/og/markup.ts +90 -0
  58. package/src/og/renderer.ts +42 -0
  59. package/src/og/types.ts +26 -0
  60. package/src/og/wawoff2.d.ts +6 -0
  61. package/src/package-imports.test.mjs +22 -0
  62. package/src/result.test.ts +149 -0
  63. package/src/result.ts +29 -0
  64. package/src/string.test.ts +205 -0
  65. package/src/string.ts +71 -0
  66. package/src/testimonials/render-content.ts +21 -0
  67. package/src/testimonials/server.ts +47 -0
  68. package/src/testimonials/types.ts +35 -0
  69. package/src/timing.test.ts +166 -0
  70. package/src/timing.ts +28 -0
  71. package/src/vite.d.ts +12 -0
  72. package/src/vite.js +265 -0
  73. package/src/vite.test.js +235 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Umami reporting API client, the read side of Umami.
3
+ */
4
+
5
+ /*
6
+ * `protocol.ts` and `client.ts` write events into Umami. This reads them back out: websites, stats, metrics, time-series, sessions and live visitors. Reference: https://docs.umami.is/docs/api
7
+ */
8
+
9
+ /*
10
+ * WHY one client for every runtime: the same dashboard is served from a Cloudflare Worker, a Node server and a terminal CLI.
11
+ */
12
+
13
+ /*
14
+ * Transport is the only thing that differs between them, so it is the only thing injected.
15
+ */
16
+
17
+ // ── Transport ──
18
+
19
+ /**
20
+ * The subset of `fetch` this client uses.
21
+ */
22
+
23
+ /*
24
+ * Structural on purpose: Node's global fetch, a Worker's fetch and undici's fetch all satisfy it, so a caller that needs a custom dispatcher (a pinned DNS resolution,
25
+ */
26
+
27
+ /*
28
+ * a proxy) can wrap its own without this module importing undici.
29
+ */
30
+ export type UmamiFetch = (
31
+ url: string,
32
+ init?: {
33
+ method?: string;
34
+ headers?: Record<string, string>;
35
+ body?: string;
36
+ },
37
+ ) => Promise<{
38
+ ok: boolean;
39
+ status: number;
40
+ statusText: string;
41
+ json: () => Promise<unknown>;
42
+ }>;
43
+
44
+ // ── Auth ──
45
+
46
+ export type UmamiAuthResponse = {
47
+ token: string;
48
+ user: { id: string; username: string };
49
+ };
50
+
51
+ // ── Websites ──
52
+
53
+ export type UmamiReport = {
54
+ id: string;
55
+ websiteId: string;
56
+ type: "funnel" | "goal" | "journey" | "retention" | "revenue" | "attribution" | "utm";
57
+ name: string;
58
+ description: string;
59
+ parameters: Record<string, unknown>;
60
+ };
61
+
62
+ export type UmamiWebsite = {
63
+ id: string;
64
+ name: string;
65
+ domain: string;
66
+ shareId: string | null;
67
+ resetAt: string | null;
68
+ userId: string | null;
69
+ teamId: string | null;
70
+ createdBy: string;
71
+ createdAt: string;
72
+ updatedAt: string;
73
+ deletedAt: string | null;
74
+ };
75
+
76
+ export type UmamiWebsitesResponse = {
77
+ data: UmamiWebsite[];
78
+ count: number;
79
+ page: number;
80
+ pageSize: number;
81
+ };
82
+
83
+ // ── Stats ──
84
+
85
+ export type UmamiStatsCore = {
86
+ pageviews: number;
87
+ visitors: number;
88
+ visits: number;
89
+ bounces: number;
90
+ totaltime: number;
91
+ };
92
+
93
+ /**
94
+ * WHY `comparison` and not `{ value, prev }`: Umami returns the current window flat and the preceding window of equal length under `comparison`.
95
+ */
96
+
97
+ /*
98
+ * Older docs describe a `{ value, prev }` shape per metric; the self-hosted server does not send it, and typing it that way yields `undefined` at every call site.
99
+ */
100
+ export type UmamiStatsResponse = UmamiStatsCore & {
101
+ comparison?: UmamiStatsCore;
102
+ };
103
+
104
+ // ── Active visitors ──
105
+
106
+ export type UmamiActiveResponse = {
107
+ visitors: number;
108
+ };
109
+
110
+ // ── Date range ──
111
+
112
+ export type UmamiDateRangeResponse = {
113
+ startDate: string;
114
+ endDate: string;
115
+ };
116
+
117
+ // ── Metrics ──
118
+
119
+ export type UmamiMetric = {
120
+ x: string;
121
+ y: number;
122
+ };
123
+
124
+ export type UmamiMetricType =
125
+ | "path"
126
+ | "entry"
127
+ | "exit"
128
+ | "title"
129
+ | "query"
130
+ | "referrer"
131
+ | "browser"
132
+ | "os"
133
+ | "device"
134
+ | "country"
135
+ | "region"
136
+ | "city"
137
+ | "language"
138
+ | "screen"
139
+ | "event"
140
+ | "hostname"
141
+ | "tag"
142
+ | "distinctId";
143
+
144
+ export type UmamiExpandedMetricRaw = {
145
+ name: string;
146
+ pageviews: string | number;
147
+ visitors: number;
148
+ visits: number;
149
+ bounces: number;
150
+ totaltime: string | number;
151
+ };
152
+
153
+ export type UmamiExpandedMetric = {
154
+ name: string;
155
+ pageviews: number;
156
+ visitors: number;
157
+ visits: number;
158
+ bounces: number;
159
+ totaltime: number;
160
+ };
161
+
162
+ // ── Pageviews (time-series) ──
163
+
164
+ export type UmamiPageviewsEntry = {
165
+ x: string;
166
+ y: number;
167
+ };
168
+
169
+ export type UmamiPageviewsResponse = {
170
+ pageviews: UmamiPageviewsEntry[];
171
+ sessions: UmamiPageviewsEntry[];
172
+ };
173
+
174
+ // ── Events ──
175
+
176
+ export type UmamiEvent = {
177
+ id: string;
178
+ websiteId: string;
179
+ sessionId: string;
180
+ createdAt: string;
181
+ urlPath: string | null;
182
+ urlQuery: string | null;
183
+ referrerPath: string | null;
184
+ referrerQuery: string | null;
185
+ referrerDomain: string | null;
186
+ country: string | null;
187
+ city: string | null;
188
+ device: string | null;
189
+ os: string | null;
190
+ browser: string | null;
191
+ pageTitle: string | null;
192
+ eventType: number;
193
+ eventName: string | null;
194
+ };
195
+
196
+ export type UmamiPaginated<T> = {
197
+ data: T[];
198
+ count: number;
199
+ page: number;
200
+ pageSize: number;
201
+ };
202
+
203
+ export type UmamiEventsResponse = UmamiPaginated<UmamiEvent>;
204
+
205
+ export type UmamiEventSeriesEntry = {
206
+ x: string;
207
+ t: string;
208
+ y: number;
209
+ };
210
+
211
+ // ── Event data ──
212
+
213
+ /** One (event, field) pair that carried numeric or string data, with its count. */
214
+ export type UmamiEventDataStat = {
215
+ eventName: string;
216
+ fieldName: string;
217
+ total: number;
218
+ };
219
+
220
+ /** One recorded value of an event field. `value` is raw, so revenue arrives as a string. */
221
+ export type UmamiEventDataValue = {
222
+ value: string;
223
+ total: number;
224
+ };
225
+
226
+ // ── Sessions ──
227
+
228
+ export type UmamiSession = {
229
+ id: string;
230
+ websiteId: string;
231
+ hostname: string | null;
232
+ browser: string | null;
233
+ os: string | null;
234
+ device: string | null;
235
+ screen: string | null;
236
+ language: string | null;
237
+ country: string | null;
238
+ region: string | null;
239
+ city: string | null;
240
+ firstAt: string;
241
+ lastAt: string;
242
+ visits: number;
243
+ views: number;
244
+ events: number;
245
+ createdAt: string;
246
+ };
247
+
248
+ export type UmamiSessionsResponse = UmamiPaginated<UmamiSession>;
249
+
250
+ // ── Credentials ──
251
+
252
+ export type UmamiReportingCredentials = {
253
+ apiUrl: string;
254
+ username?: string;
255
+ password?: string;
256
+ /** Umami Cloud key. Replaces the login; `apiUrl` is then `https://api.umami.is/v1`. */
257
+ apiKey?: string;
258
+ /** Cloud's free plan has no key: the `__Secure-umami.session_token` cookie, as `name=value`. */
259
+ sessionCookie?: string;
260
+ /** Include websites owned by this team alongside the personal ones. */
261
+ teamId?: string;
262
+ /**
263
+ * Cloudflare Access service token.
264
+ */
265
+
266
+ /*
267
+ * WHY: an Umami host behind Access answers an unauthenticated API call with a 302 to the login page, whose HTML body parses as "no websites" rather than as an error.
268
+ */
269
+ accessClientId?: string;
270
+ accessClientSecret?: string;
271
+ /** Transport override. Defaults to the runtime's global fetch. */
272
+ fetchImpl?: UmamiFetch;
273
+ };
274
+
275
+ const TOKEN_TTL_MS = 30 * 60 * 1000;
276
+ const WEBSITE_LIST_TTL_MS = 10 * 60 * 1000;
277
+ const PAGE_SIZE = 150;
278
+
279
+ export class UmamiReportingClient {
280
+ private baseUrl: string;
281
+ private username?: string;
282
+ private password?: string;
283
+ private apiKey?: string;
284
+ private sessionCookie?: string;
285
+ private teamId?: string;
286
+ private accessHeaders: Record<string, string>;
287
+ private fetchImpl: UmamiFetch;
288
+
289
+ private cachedToken: string | null = null;
290
+ private tokenExpiry = 0;
291
+ private cachedWebsites: UmamiWebsite[] | null = null;
292
+ private websitesExpiry = 0;
293
+
294
+ constructor(credentials: UmamiReportingCredentials) {
295
+ this.baseUrl = credentials.apiUrl.replace(/\/+$/, "");
296
+ this.username = credentials.username;
297
+ this.password = credentials.password;
298
+ this.apiKey = credentials.apiKey;
299
+ this.sessionCookie = credentials.sessionCookie;
300
+ this.teamId = credentials.teamId;
301
+ this.accessHeaders =
302
+ credentials.accessClientId && credentials.accessClientSecret
303
+ ? {
304
+ "CF-Access-Client-Id": credentials.accessClientId,
305
+ "CF-Access-Client-Secret": credentials.accessClientSecret,
306
+ }
307
+ : {};
308
+
309
+ const injected = credentials.fetchImpl;
310
+ if (!injected && typeof globalThis.fetch !== "function") {
311
+ throw new Error("No fetch available. Pass fetchImpl to UmamiReportingClient.");
312
+ }
313
+ this.fetchImpl = injected ?? ((url, init) => globalThis.fetch(url, init));
314
+ }
315
+
316
+ // ── Auth ──
317
+
318
+ private async getToken(): Promise<string> {
319
+ if (this.cachedToken && Date.now() < this.tokenExpiry) return this.cachedToken;
320
+
321
+ const response = await this.fetchImpl(`${this.baseUrl}/api/auth/login`, {
322
+ method: "POST",
323
+ headers: { "Content-Type": "application/json", ...this.accessHeaders },
324
+ // WHY both keys: Umami matches on `username`, but a server configured for
325
+ // email login reads `email`. Sending both covers either without a probe.
326
+ body: JSON.stringify({
327
+ username: this.username,
328
+ email: this.username,
329
+ password: this.password,
330
+ }),
331
+ });
332
+
333
+ if (!response.ok) {
334
+ throw new Error(`Umami auth failed: ${response.status} ${response.statusText}`);
335
+ }
336
+
337
+ const data = (await response.json()) as UmamiAuthResponse;
338
+ this.cachedToken = data.token;
339
+ this.tokenExpiry = Date.now() + TOKEN_TTL_MS;
340
+ return data.token;
341
+ }
342
+
343
+ private async request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
344
+ // Cloud serves the same routes without the `/api` prefix, under `/v1`.
345
+ const url = this.apiKey
346
+ ? `${this.baseUrl}${path.replace(/^\/api/, "")}`
347
+ : `${this.baseUrl}${path}`;
348
+ const auth: Record<string, string> = this.apiKey
349
+ ? { "x-umami-api-key": this.apiKey }
350
+ : this.sessionCookie
351
+ ? { Cookie: this.sessionCookie }
352
+ : { Authorization: `Bearer ${await this.getToken()}` };
353
+ const response = await this.fetchImpl(url, {
354
+ method,
355
+ headers: {
356
+ ...auth,
357
+ "Content-Type": "application/json",
358
+ ...this.accessHeaders,
359
+ },
360
+ body: body === undefined ? undefined : JSON.stringify(body),
361
+ });
362
+
363
+ if (response.status === 401) {
364
+ this.cachedToken = null;
365
+ this.tokenExpiry = 0;
366
+ throw new Error("Umami auth token expired");
367
+ }
368
+
369
+ if (!response.ok) {
370
+ throw new Error(`Umami API error: ${response.status} ${response.statusText} (${path})`);
371
+ }
372
+
373
+ // A delete answers `ok` as plain text, so parsing it would throw on a call
374
+ // that succeeded.
375
+ if (method === "DELETE") return undefined as T;
376
+ return (await response.json()) as T;
377
+ }
378
+
379
+ // ── Websites ──
380
+
381
+ /** Every website this account can read, personal plus team, deduped by id. */
382
+ async getWebsites(): Promise<UmamiWebsite[]> {
383
+ if (this.cachedWebsites && Date.now() < this.websitesExpiry) return this.cachedWebsites;
384
+
385
+ const personal = await this.request<UmamiWebsitesResponse>(
386
+ `/api/websites?pageSize=${PAGE_SIZE}`,
387
+ );
388
+
389
+ let teamSites: UmamiWebsite[] = [];
390
+ if (this.teamId) {
391
+ const team = await this.request<UmamiWebsitesResponse>(
392
+ `/api/teams/${this.teamId}/websites?pageSize=${PAGE_SIZE}`,
393
+ );
394
+ teamSites = team.data ?? [];
395
+ }
396
+
397
+ const seen = new Set<string>();
398
+ const websites: UmamiWebsite[] = [];
399
+ for (const site of [...(personal.data ?? []), ...teamSites]) {
400
+ if (seen.has(site.id)) continue;
401
+ seen.add(site.id);
402
+ websites.push(site);
403
+ }
404
+
405
+ this.cachedWebsites = websites;
406
+ this.websitesExpiry = Date.now() + WEBSITE_LIST_TTL_MS;
407
+ return websites;
408
+ }
409
+
410
+ /**
411
+ * Register a website, which is what a new marketing site needs before a tracker id exists to put in its layout.
412
+ */
413
+
414
+ /*
415
+ * WHY a write on a reporting client: the same reason as `deleteWebsite` below. This is the one authenticated door to the host.
416
+ */
417
+ async createWebsite(input: {
418
+ name: string;
419
+ domain: string;
420
+ teamId?: string;
421
+ }): Promise<UmamiWebsite> {
422
+ const site = await this.request<UmamiWebsite>("/api/websites", "POST", {
423
+ name: input.name,
424
+ domain: input.domain,
425
+ ...(input.teamId ? { teamId: input.teamId } : {}),
426
+ });
427
+ this.cachedWebsites = null;
428
+ this.websitesExpiry = 0;
429
+ return site;
430
+ }
431
+
432
+ // ── Saved reports: the funnels and goals the dashboard draws ──
433
+
434
+ async getReports(websiteId: string): Promise<UmamiReport[]> {
435
+ const page = await this.request<{ data: UmamiReport[] } | UmamiReport[]>(
436
+ `/api/reports?websiteId=${websiteId}&pageSize=200`,
437
+ );
438
+ return Array.isArray(page) ? page : page.data;
439
+ }
440
+
441
+ async createReport(input: Omit<UmamiReport, "id">): Promise<UmamiReport> {
442
+ return this.request<UmamiReport>("/api/reports", "POST", input);
443
+ }
444
+
445
+ /**
446
+ * Remove a website and everything tracked under it. There is no undo.
447
+ */
448
+
449
+ /*
450
+ * WHY the one write on a reporting client: this is the only authenticated door to the host, and a second class would be a second place to carry the Cloudflare
451
+ */
452
+
453
+ /*
454
+ * Access headers and the token cache. Reached from `pnpm umami websites:delete` and nowhere else.
455
+ */
456
+ async deleteWebsite(websiteId: string): Promise<void> {
457
+ await this.request(`/api/websites/${websiteId}`, "DELETE");
458
+ this.cachedWebsites = null;
459
+ this.websitesExpiry = 0;
460
+ }
461
+
462
+ // ── Stats ──
463
+
464
+ async getStats(websiteId: string, startAt: number, endAt: number): Promise<UmamiStatsResponse> {
465
+ return this.request<UmamiStatsResponse>(
466
+ `/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`,
467
+ );
468
+ }
469
+
470
+ async getDateRange(websiteId: string): Promise<UmamiDateRangeResponse> {
471
+ return this.request<UmamiDateRangeResponse>(`/api/websites/${websiteId}/daterange`);
472
+ }
473
+
474
+ // ── Active visitors ──
475
+
476
+ async getActive(websiteId: string): Promise<number> {
477
+ const data = await this.request<UmamiActiveResponse>(`/api/websites/${websiteId}/active`);
478
+ return data.visitors ?? 0;
479
+ }
480
+
481
+ // ── Metrics ──
482
+
483
+ async getMetrics(
484
+ websiteId: string,
485
+ type: UmamiMetricType,
486
+ startAt: number,
487
+ endAt: number,
488
+ limit = 20,
489
+ ): Promise<UmamiMetric[]> {
490
+ return this.request<UmamiMetric[]>(
491
+ `/api/websites/${websiteId}/metrics?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`,
492
+ );
493
+ }
494
+
495
+ async getExpandedMetrics(
496
+ websiteId: string,
497
+ type: UmamiMetricType,
498
+ startAt: number,
499
+ endAt: number,
500
+ limit = 20,
501
+ ): Promise<UmamiExpandedMetric[]> {
502
+ const raw = await this.request<UmamiExpandedMetricRaw[]>(
503
+ `/api/websites/${websiteId}/metrics/expanded?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`,
504
+ );
505
+ return raw.map((entry) => ({
506
+ name: entry.name,
507
+ pageviews: Number(entry.pageviews),
508
+ visitors: entry.visitors,
509
+ visits: entry.visits,
510
+ bounces: entry.bounces,
511
+ totaltime: Number(entry.totaltime),
512
+ }));
513
+ }
514
+
515
+ // ── Pageviews (time-series) ──
516
+
517
+ async getPageviews(
518
+ websiteId: string,
519
+ startAt: number,
520
+ endAt: number,
521
+ unit = "day",
522
+ timezone = "UTC",
523
+ ): Promise<UmamiPageviewsResponse> {
524
+ return this.request<UmamiPageviewsResponse>(
525
+ `/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` +
526
+ `&timezone=${encodeURIComponent(timezone)}`,
527
+ );
528
+ }
529
+
530
+ // ── Events ──
531
+
532
+ async getEvents(
533
+ websiteId: string,
534
+ startAt: number,
535
+ endAt: number,
536
+ pageSize = 50,
537
+ ): Promise<UmamiEventsResponse> {
538
+ return this.request<UmamiEventsResponse>(
539
+ `/api/websites/${websiteId}/events?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`,
540
+ );
541
+ }
542
+
543
+ async getEventSeries(
544
+ websiteId: string,
545
+ startAt: number,
546
+ endAt: number,
547
+ unit = "day",
548
+ timezone = "UTC",
549
+ ): Promise<UmamiEventSeriesEntry[]> {
550
+ return this.request<UmamiEventSeriesEntry[]>(
551
+ `/api/websites/${websiteId}/events/series?startAt=${startAt}&endAt=${endAt}&unit=${unit}` +
552
+ `&timezone=${encodeURIComponent(timezone)}`,
553
+ );
554
+ }
555
+
556
+ // ── Event data ──
557
+
558
+ /** Every (event, field) pair recorded in the window. The index for a rollup. */
559
+ async getEventDataStats(
560
+ websiteId: string,
561
+ startAt: number,
562
+ endAt: number,
563
+ ): Promise<UmamiEventDataStat[]> {
564
+ return this.request<UmamiEventDataStat[]>(
565
+ `/api/websites/${websiteId}/event-data/stats?startAt=${startAt}&endAt=${endAt}`,
566
+ );
567
+ }
568
+
569
+ /** The values one event field took in the window, e.g. every revenue amount. */
570
+ async getEventDataValues(
571
+ websiteId: string,
572
+ startAt: number,
573
+ endAt: number,
574
+ event: string,
575
+ field: string,
576
+ ): Promise<UmamiEventDataValue[]> {
577
+ // WHY URLSearchParams: an event name may carry spaces or a slash, which break a hand-built query string. WHY `propertyName` and not `property`: the self-hosted server validates
578
+
579
+ // the query and answers `property` with a 400 naming `propertyName` as the missing field, so every call through this method failed until 24 Aug 2026. The docs say `property`.
580
+
581
+ // The server is the authority.
582
+ const params = new URLSearchParams({
583
+ startAt: String(startAt),
584
+ endAt: String(endAt),
585
+ event,
586
+ propertyName: field,
587
+ });
588
+ return this.request<UmamiEventDataValue[]>(
589
+ `/api/websites/${websiteId}/event-data/values?${params.toString()}`,
590
+ );
591
+ }
592
+
593
+ // ── Sessions ──
594
+
595
+ async getSessions(
596
+ websiteId: string,
597
+ startAt: number,
598
+ endAt: number,
599
+ pageSize = 50,
600
+ ): Promise<UmamiSessionsResponse> {
601
+ return this.request<UmamiSessionsResponse>(
602
+ `/api/websites/${websiteId}/sessions?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`,
603
+ );
604
+ }
605
+
606
+ // ── Helpers ──
607
+
608
+ /** Resolve a site identifier (UUID, exact domain, or name substring) to its id. */
609
+ async resolveSiteId(siteArg: string): Promise<string> {
610
+ if (/^[0-9a-f-]{36}$/i.test(siteArg)) return siteArg;
611
+
612
+ const websites = await this.getWebsites();
613
+ const lower = siteArg.toLowerCase();
614
+
615
+ const byDomain = websites.find((w) => w.domain.toLowerCase() === lower);
616
+ if (byDomain) return byDomain.id;
617
+
618
+ const byPartial = websites.find(
619
+ (w) => w.domain.toLowerCase().includes(lower) || w.name.toLowerCase().includes(lower),
620
+ );
621
+ if (byPartial) return byPartial.id;
622
+
623
+ throw new Error(
624
+ `No website found matching "${siteArg}". Run \`umami websites\` to see available sites.`,
625
+ );
626
+ }
627
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The email OTP every app signs in with: 6 digits, good for 5 minutes. Spread it into
3
+ * better-auth's plugin: `emailOTP({ ...otpDefaults, sendVerificationOTP })`.
4
+ */
5
+ export const otpDefaults = {
6
+ otpLength: 6,
7
+ expiresIn: 300,
8
+ } as const;
@@ -0,0 +1,11 @@
1
+ export const defaultRateLimits = {
2
+ enabled: true,
3
+ storage: "memory" as const,
4
+ window: 60,
5
+ max: 100,
6
+ customRules: {
7
+ "/email-otp/send-verification-otp": { window: 60, max: 3 },
8
+ "/sign-in/email-otp": { window: 10, max: 3 },
9
+ "/email-otp/verify-email": { window: 10, max: 3 },
10
+ },
11
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Returns `trustedOrigins` for Better Auth.
3
+ */
4
+
5
+ /*
6
+ * Includes `siteUrl` plus any origins listed in `TRUSTED_ORIGINS` (comma-separated). This handles the case where one Convex deployment serves both production and development frontends.
7
+ */
8
+
9
+ /*
10
+ * Set `TRUSTED_ORIGINS` in your Convex env vars. Dev Convex (serves only localhost): not needed, SITE_URL is enough. Prod Convex (serves prod + localhost): TRUSTED_ORIGINS=http://localhost:3060
11
+ */
12
+
13
+ /*
14
+ * - Prod Convex (serves prod only): not needed
15
+ */
16
+
17
+ /*
18
+ * @example ```ts betterAuth({ baseURL: siteUrl, trustedOrigins: defaultTrustedOrigins(siteUrl), }) ```
19
+ */
20
+ export function defaultTrustedOrigins(siteUrl: string): string[] {
21
+ const origins = new Set([siteUrl]);
22
+
23
+ const extra = process.env.TRUSTED_ORIGINS;
24
+ if (extra) {
25
+ for (const origin of extra.split(",")) {
26
+ const trimmed = origin.trim();
27
+ if (trimmed) origins.add(trimmed);
28
+ }
29
+ }
30
+
31
+ return [...origins];
32
+ }
@@ -0,0 +1,7 @@
1
+ // The email surface: the OTP mail, the shared layout and a Resend sender. No better-auth import,
2
+ // so a Worker can bundle it.
3
+ export { buildOtpEmail } from "#src/auth/email/template.js";
4
+ export { createResendSender } from "#src/auth/email/resend.js";
5
+ export { renderEmail, resolvePalette, escapeHtml, MONO_STACK } from "#src/auth/email/layout.js";
6
+ export type { EmailContent } from "#src/auth/email/layout.js";
7
+ export type { EmailBrand, EmailOptions, OtpBrand, OtpType } from "#src/auth/email/types.js";