@heyramzi/utils 0.0.0-stage → 1.13.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/dist/ai/image.js +220 -0
  4. package/dist/ai/models.js +261 -0
  5. package/dist/analytics/client.js +57 -0
  6. package/dist/analytics/server.js +54 -0
  7. package/dist/analytics/svelte.js +24 -0
  8. package/dist/analytics/types.js +0 -0
  9. package/dist/analytics/umami/protocol.js +35 -0
  10. package/dist/analytics/umami/reporting.js +189 -0
  11. package/dist/auth/config/otp.js +4 -0
  12. package/dist/auth/config/rate-limits.js +20 -0
  13. package/dist/auth/config/trusted-origins.js +15 -0
  14. package/dist/auth/email/index.js +3 -0
  15. package/dist/auth/email/layout.js +84 -0
  16. package/dist/auth/email/resend.js +30 -0
  17. package/dist/auth/email/template.js +57 -0
  18. package/dist/auth/email/types.js +0 -0
  19. package/dist/auth/index.js +3 -0
  20. package/dist/cache.js +4 -0
  21. package/dist/cn.js +5 -0
  22. package/dist/collection.js +8 -0
  23. package/dist/crypto.js +117 -0
  24. package/dist/error.js +15 -0
  25. package/dist/errors/sentry/index.js +15 -0
  26. package/dist/errors/sentry/protocol.js +130 -0
  27. package/dist/errors/sentry/sveltekit.js +145 -0
  28. package/dist/format.js +62 -0
  29. package/dist/grab.js +112 -0
  30. package/dist/index.js +10 -0
  31. package/dist/logger-types.js +0 -0
  32. package/dist/logger.js +22 -0
  33. package/dist/math.js +3 -0
  34. package/dist/og/fonts.js +46 -0
  35. package/dist/og/index.js +3 -0
  36. package/dist/og/markup.js +31 -0
  37. package/dist/og/renderer.js +29 -0
  38. package/dist/og/types.js +0 -0
  39. package/dist/result.js +26 -0
  40. package/dist/string.js +32 -0
  41. package/dist/testimonials/render-content.js +15 -0
  42. package/dist/testimonials/server.js +21 -0
  43. package/dist/testimonials/types.js +0 -0
  44. package/dist/timing.js +20 -0
  45. package/dist/vite.js +265 -0
  46. package/package.json +201 -4
  47. package/src/ai/image-agnes.test.ts +130 -0
  48. package/src/ai/image.ts +435 -0
  49. package/src/ai/models.test.ts +98 -0
  50. package/src/ai/models.ts +450 -0
  51. package/src/analytics/client.test.ts +149 -0
  52. package/src/analytics/client.ts +129 -0
  53. package/src/analytics/server.test.ts +165 -0
  54. package/src/analytics/server.ts +88 -0
  55. package/src/analytics/svelte.test.ts +105 -0
  56. package/src/analytics/svelte.ts +58 -0
  57. package/src/analytics/types.ts +26 -0
  58. package/src/analytics/umami/protocol.test.ts +18 -0
  59. package/src/analytics/umami/protocol.ts +104 -0
  60. package/src/analytics/umami/reporting.test.ts +391 -0
  61. package/src/analytics/umami/reporting.ts +627 -0
  62. package/src/auth/config/otp.ts +8 -0
  63. package/src/auth/config/rate-limits.ts +11 -0
  64. package/src/auth/config/trusted-origins.ts +32 -0
  65. package/src/auth/email/index.ts +7 -0
  66. package/src/auth/email/layout.test.ts +73 -0
  67. package/src/auth/email/layout.ts +122 -0
  68. package/src/auth/email/resend.test.ts +35 -0
  69. package/src/auth/email/resend.ts +26 -0
  70. package/src/auth/email/template.ts +77 -0
  71. package/src/auth/email/types.ts +74 -0
  72. package/src/auth/index.ts +5 -0
  73. package/src/cache.test.ts +67 -0
  74. package/src/cache.ts +5 -0
  75. package/src/cn.test.ts +52 -0
  76. package/src/cn.ts +16 -0
  77. package/src/collection.test.ts +66 -0
  78. package/src/collection.ts +8 -0
  79. package/src/crypto.test.ts +86 -0
  80. package/src/crypto.ts +157 -0
  81. package/src/error.test.ts +79 -0
  82. package/src/error.ts +16 -0
  83. package/src/errors/sentry/index.ts +20 -0
  84. package/src/errors/sentry/protocol.test.ts +85 -0
  85. package/src/errors/sentry/protocol.ts +144 -0
  86. package/src/errors/sentry/sveltekit.test.ts +149 -0
  87. package/src/errors/sentry/sveltekit.ts +226 -0
  88. package/src/format.test.ts +225 -0
  89. package/src/format.ts +103 -0
  90. package/src/grab.d.ts +2 -0
  91. package/src/grab.js +112 -0
  92. package/src/index.ts +38 -0
  93. package/src/logger-types.ts +14 -0
  94. package/src/logger.ts +24 -0
  95. package/src/math.test.ts +41 -0
  96. package/src/math.ts +3 -0
  97. package/src/og/fonts.ts +69 -0
  98. package/src/og/index.ts +5 -0
  99. package/src/og/markup.test.ts +73 -0
  100. package/src/og/markup.ts +90 -0
  101. package/src/og/renderer.ts +42 -0
  102. package/src/og/types.ts +26 -0
  103. package/src/og/wawoff2.d.ts +6 -0
  104. package/src/package-imports.test.mjs +22 -0
  105. package/src/result.test.ts +149 -0
  106. package/src/result.ts +29 -0
  107. package/src/string.test.ts +205 -0
  108. package/src/string.ts +71 -0
  109. package/src/testimonials/render-content.ts +21 -0
  110. package/src/testimonials/server.ts +47 -0
  111. package/src/testimonials/types.ts +35 -0
  112. package/src/timing.test.ts +166 -0
  113. package/src/timing.ts +28 -0
  114. package/src/vite.d.ts +12 -0
  115. package/src/vite.js +265 -0
  116. package/src/vite.test.js +235 -0
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { escapeHtml, renderEmail } from "#src/auth/email/layout";
3
+ import { buildOtpEmail } from "#src/auth/email/template";
4
+
5
+ const brand = {
6
+ name: "CBAM Data",
7
+ url: "https://cbamdata.eu",
8
+ color: "#0b6b45",
9
+ logoUrl: "https://cbamdata.eu/favicon.png",
10
+ palette: { background: "#f4f2ea", ink: "#10170f", radius: 4 },
11
+ };
12
+
13
+ describe("renderEmail", () => {
14
+ it("draws the brand palette, the name and the domain", () => {
15
+ const { html } = renderEmail(brand, { preview: "p", heading: "Hello" });
16
+ expect(html).toContain("background-color:#f4f2ea");
17
+ expect(html).toContain("color:#10170f");
18
+ expect(html).toContain("border-radius:4px");
19
+ expect(html).toContain(">CBAM Data</span>");
20
+ expect(html).toContain(">cbamdata.eu</a>");
21
+ // Unset palette keys keep the neutral defaults.
22
+ expect(html).toContain("background-color:#ffffff");
23
+ });
24
+
25
+ it("escapes every plain-text field, never the trusted body", () => {
26
+ const { html } = renderEmail(brand, {
27
+ preview: "<p>",
28
+ heading: "<h>",
29
+ intro: "a & b",
30
+ note: '"n"',
31
+ body: { html: "<strong>kept</strong>", text: "kept" },
32
+ action: { label: "<go>", url: "https://cbamdata.eu/?a=1&b=2" },
33
+ });
34
+ expect(html).not.toContain("<h>");
35
+ expect(html).toContain("a &#38; b");
36
+ expect(html).toContain("<strong>kept</strong>");
37
+ expect(html).toContain('href="https://cbamdata.eu/?a=1&#38;b=2"');
38
+ expect(html).toContain("background-color:#0b6b45");
39
+ });
40
+
41
+ it("writes a plain-text twin in reading order", () => {
42
+ const { text } = renderEmail(brand, {
43
+ preview: "p",
44
+ heading: "Heading",
45
+ intro: "Intro",
46
+ body: { html: "", text: "Body" },
47
+ action: { label: "Open", url: "https://cbamdata.eu/x" },
48
+ note: "Note",
49
+ });
50
+ expect(text).toBe(
51
+ "Heading\n\nIntro\n\nBody\n\nOpen: https://cbamdata.eu/x\n\nNote\n\n---\nCBAM Data · cbamdata.eu",
52
+ );
53
+ });
54
+ });
55
+
56
+ describe("buildOtpEmail", () => {
57
+ it("renders the code as one copyable run inside the shared layout", () => {
58
+ const { subject, html, text } = buildOtpEmail(brand)("123456", "sign-in");
59
+ expect(subject).toBe("CBAM Data sign-in code");
60
+ expect(html).toMatch(/>123456<\/span>/);
61
+ expect(html).toContain("background-color:#f4f2ea");
62
+ expect(html).toContain("rgba(11,107,69,0.35)");
63
+ expect(text).toContain("Your code: 123456");
64
+ });
65
+ });
66
+
67
+ describe("escapeHtml", () => {
68
+ it("escapes the five markup characters", () => {
69
+ expect(escapeHtml(`<a href="x">'&'</a>`)).toBe(
70
+ "&#60;a href=&#34;x&#34;&#62;&#39;&#38;&#39;&#60;/a&#62;",
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,122 @@
1
+ import type { EmailBrand, EmailPalette } from "#src/auth/email/types.js";
2
+
3
+ // The one email shell every message renders through: the OTP code, a product
4
+ // alert, an operator notice. Tables and inline styles only, for Outlook and Gmail.
5
+
6
+ const DEFAULT_PALETTE: EmailPalette = {
7
+ background: "#f4f4f5",
8
+ surface: "#ffffff",
9
+ ink: "#18181b",
10
+ muted: "#71717a",
11
+ subtle: "#a1a1aa",
12
+ rule: "#ececec",
13
+ radius: 14,
14
+ };
15
+
16
+ export const FONT_STACK =
17
+ "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
18
+ export const MONO_STACK =
19
+ "'SF Mono',Monaco,'Cascadia Code','Roboto Mono',Consolas,'Courier New',monospace";
20
+
21
+ export function resolvePalette(brand: EmailBrand): EmailPalette {
22
+ return { ...DEFAULT_PALETTE, ...brand.palette };
23
+ }
24
+
25
+ export function escapeHtml(value: string): string {
26
+ return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
27
+ }
28
+
29
+ /** Parses `#rgb` or `#rrggbb`; null for any other CSS color, so callers fall back. */
30
+ export function hexToRgb(hex: string): [number, number, number] | null {
31
+ const match = /^#?([a-f\d]{3}|[a-f\d]{6})$/i.exec(hex.trim());
32
+ if (!match) return null;
33
+ const full = match[1].length === 3 ? [...match[1]].map((c) => c + c).join("") : match[1];
34
+ const n = Number.parseInt(full, 16);
35
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
36
+ }
37
+
38
+ export type EmailContent = {
39
+ /** Inbox preview line, hidden in the body. Plain text. */
40
+ preview: string;
41
+ /** Plain text. */
42
+ heading: string;
43
+ /** Plain text. */
44
+ intro?: string;
45
+ /** The message itself: `html` is trusted markup, `text` its plain-text twin. */
46
+ body?: { html: string; text: string };
47
+ action?: { label: string; url: string };
48
+ /** Small print under a rule: why the reader got this. Plain text. */
49
+ note?: string;
50
+ /** Centred suits a code; left suits a list. Default centre. */
51
+ align?: "center" | "left";
52
+ };
53
+
54
+ /** Renders the branded HTML and plain-text parts of one email. */
55
+ export function renderEmail(
56
+ brand: EmailBrand,
57
+ content: EmailContent,
58
+ ): { html: string; text: string } {
59
+ const { name, url, color = "#6366f1", logoUrl, tagline } = brand;
60
+ const p = resolvePalette(brand);
61
+ const align = content.align ?? "center";
62
+ const domain = url.replace(/^https?:\/\//, "").replace(/\/$/, "");
63
+ const year = new Date().getFullYear();
64
+
65
+ const row = (html: string, padding: string, rowAlign = align) =>
66
+ `<tr><td align="${rowAlign}" style="padding:${padding};text-align:${rowAlign};">${html}</td></tr>`;
67
+
68
+ const brandRow = row(
69
+ `<table role="presentation" cellspacing="0" cellpadding="0"${align === "center" ? ' align="center"' : ""}><tr>
70
+ ${logoUrl ? `<td style="padding-right:9px;vertical-align:middle;"><img src="${escapeHtml(logoUrl)}" alt="" width="26" height="26" style="display:block;border:0;border-radius:${Math.min(p.radius, 6)}px;" /></td>` : ""}
71
+ <td style="vertical-align:middle;"><span style="font-size:15px;font-weight:700;color:${p.ink};letter-spacing:-0.01em;">${escapeHtml(name)}</span></td>
72
+ </tr></table>`,
73
+ "32px 32px 20px",
74
+ );
75
+
76
+ const action = content.action
77
+ ? row(
78
+ `<table role="presentation" cellspacing="0" cellpadding="0"${align === "center" ? ' align="center"' : ""}><tr><td style="background-color:${color};border-radius:${Math.min(p.radius, 8)}px;">
79
+ <a href="${escapeHtml(content.action.url)}" style="display:inline-block;padding:11px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${escapeHtml(content.action.label)}</a>
80
+ </td></tr></table>`,
81
+ "4px 32px 24px",
82
+ )
83
+ : "";
84
+
85
+ const html = `<!DOCTYPE html>
86
+ <html lang="en">
87
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="color-scheme" content="light"><meta name="supported-color-schemes" content="light"><title>${escapeHtml(content.heading)}</title></head>
88
+ <body style="margin:0;padding:0;background-color:${p.background};font-family:${FONT_STACK};color:${p.ink};-webkit-font-smoothing:antialiased;">
89
+ <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(content.preview)}</div>
90
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:${p.background};">
91
+ <tr><td align="center" style="padding:40px 16px 32px;">
92
+ <table role="presentation" cellspacing="0" cellpadding="0" style="max-width:520px;width:100%;background-color:${p.surface};border-radius:${p.radius}px;border:1px solid ${p.rule};">
93
+ ${brandRow}
94
+ ${row(`<h1 style="margin:0;font-size:18px;line-height:1.35;font-weight:600;color:${p.ink};">${escapeHtml(content.heading)}</h1>`, "0 32px")}
95
+ ${content.intro ? row(`<p style="margin:0;font-size:14px;line-height:1.55;color:${p.muted};">${escapeHtml(content.intro)}</p>`, "8px 32px 0") : ""}
96
+ ${content.body ? row(content.body.html, "24px 32px") : row("", "12px 32px 0")}
97
+ ${action}
98
+ ${content.note ? row(`<p style="margin:0;padding-top:16px;border-top:1px solid ${p.rule};font-size:12px;line-height:1.6;color:${p.subtle};">${escapeHtml(content.note)}</p>`, "0 32px 28px") : row("", "0 0 8px")}
99
+ </table>
100
+ <table role="presentation" cellspacing="0" cellpadding="0" style="max-width:520px;width:100%;">
101
+ <tr><td align="center" style="padding:18px 32px 0;">
102
+ <p style="margin:0;font-size:11px;color:${p.subtle};">&copy; ${year} ${escapeHtml(name)}${tagline ? ` &middot; ${escapeHtml(tagline)}` : ""} &middot; <a href="${escapeHtml(url)}" style="color:${p.subtle};text-decoration:underline;">${escapeHtml(domain)}</a></p>
103
+ </td></tr>
104
+ </table>
105
+ </td></tr>
106
+ </table>
107
+ </body>
108
+ </html>`;
109
+
110
+ const text = [
111
+ content.heading,
112
+ content.intro,
113
+ content.body?.text,
114
+ content.action ? `${content.action.label}: ${content.action.url}` : undefined,
115
+ content.note,
116
+ `---\n${name} · ${domain}`,
117
+ ]
118
+ .filter((part): part is string => Boolean(part))
119
+ .join("\n\n");
120
+
121
+ return { html, text };
122
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi, afterEach } from "vite-plus/test";
2
+ import { createResendSender } from "#src/auth/email/resend";
3
+
4
+ describe("createResendSender", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it("includes the plain-text alternative in the Resend payload", async () => {
10
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "" });
11
+ vi.stubGlobal("fetch", fetchMock);
12
+
13
+ const send = createResendSender("re_test", "hello@clientglance.com");
14
+ await send("user@example.com", "Your code", "<p>123456</p>", "Your code is 123456");
15
+
16
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
17
+ expect(body.text).toBe("Your code is 123456");
18
+ expect(body.html).toBe("<p>123456</p>");
19
+ });
20
+
21
+ it("passes reply-to and headers through when given", async () => {
22
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "" });
23
+ vi.stubGlobal("fetch", fetchMock);
24
+
25
+ const send = createResendSender("re_test", "CBAM Data <noreply@cbamdata.eu>");
26
+ await send("user@example.com", "Alert", "<p>x</p>", "x", {
27
+ replyTo: "support@cbamdata.eu",
28
+ headers: { "List-Unsubscribe": "<https://cbamdata.eu/watches>" },
29
+ });
30
+
31
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
32
+ expect(body.reply_to).toBe("support@cbamdata.eu");
33
+ expect(body.headers).toEqual({ "List-Unsubscribe": "<https://cbamdata.eu/watches>" });
34
+ });
35
+ });
@@ -0,0 +1,26 @@
1
+ import type { EmailSender } from "#src/auth/email/types.js";
2
+
3
+ export function createResendSender(apiKey: string, from: string): EmailSender {
4
+ return async (to, subject, html, text, options = {}) => {
5
+ const res = await fetch("https://api.resend.com/emails", {
6
+ method: "POST",
7
+ headers: {
8
+ Authorization: `Bearer ${apiKey}`,
9
+ "Content-Type": "application/json",
10
+ },
11
+ body: JSON.stringify({
12
+ from,
13
+ to: [to],
14
+ subject,
15
+ html,
16
+ text,
17
+ ...(options.replyTo ? { reply_to: options.replyTo } : {}),
18
+ ...(options.headers ? { headers: options.headers } : {}),
19
+ }),
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.text().catch(() => "");
23
+ throw new Error(`Resend failed (${res.status}): ${body}`);
24
+ }
25
+ };
26
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ escapeHtml,
3
+ hexToRgb,
4
+ FONT_STACK,
5
+ renderEmail,
6
+ resolvePalette,
7
+ } from "#src/auth/email/layout.js";
8
+ import type { EmailBrand, OtpType } from "#src/auth/email/types.js";
9
+
10
+ const LABELS: Record<
11
+ OtpType,
12
+ { subject: (otp: string, app: string) => string; heading: string; instruction: string }
13
+ > = {
14
+ "sign-in": {
15
+ subject: (_otp, app) => `${app} sign-in code`,
16
+ heading: "Sign-in code",
17
+ instruction: "Enter this code to sign in to your account.",
18
+ },
19
+ "email-verification": {
20
+ subject: (_otp, app) => `Verify your ${app} email`,
21
+ heading: "Verify your email",
22
+ instruction: "Enter this code to verify your email address.",
23
+ },
24
+ "reset-password": {
25
+ subject: (_otp, app) => `Reset your ${app} password`,
26
+ heading: "Reset your password",
27
+ instruction: "Enter this code to reset your password.",
28
+ },
29
+ "forget-password": {
30
+ subject: (_otp, app) => `Reset your ${app} password`,
31
+ heading: "Reset your password",
32
+ instruction: "Enter this code to reset your password.",
33
+ },
34
+ "change-email": {
35
+ subject: (_otp, app) => `Confirm your new ${app} email`,
36
+ heading: "Confirm email change",
37
+ instruction: "Enter this code to confirm your new email address.",
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Returns a curried OTP email builder for the given brand.
43
+ *
44
+ * Usage:
45
+ * const email = buildOtpEmail({ name: "MyApp", url: "https://myapp.com" })
46
+ * const { subject, html, text } = email("123456", "sign-in")
47
+ */
48
+ export function buildOtpEmail(brand: EmailBrand) {
49
+ return (otp: string, type: OtpType = "sign-in") => {
50
+ const { name, color = "#6366f1", expirationMinutes = 5 } = brand;
51
+ const p = resolvePalette(brand);
52
+ const label = LABELS[type];
53
+ const rgb = hexToRgb(color);
54
+ const cellBorder = rgb ? `rgba(${rgb.join(",")},0.35)` : color;
55
+ const cellBg = rgb ? `rgba(${rgb.join(",")},0.07)` : p.background;
56
+
57
+ // One text run, never a cell per digit: split cells copy as six lines, so a
58
+ // reader could not paste the code. letter-spacing spaces it without adding characters.
59
+ const codeCell = `<td align="center" valign="middle" style="padding:12px 22px;border:1.5px solid ${cellBorder};border-radius:${Math.min(p.radius, 8)}px;background-color:${cellBg};">
60
+ <span style="font-size:30px;font-weight:500;letter-spacing:6px;color:${p.ink};font-family:${FONT_STACK};font-variant-numeric:tabular-nums;-webkit-user-select:all;user-select:all;">${escapeHtml(otp)}</span>
61
+ </td>`;
62
+
63
+ const { html, text } = renderEmail(brand, {
64
+ preview: `${otp}: ${label.instruction}`,
65
+ heading: label.heading,
66
+ intro: label.instruction,
67
+ body: {
68
+ html: `<table role="presentation" cellspacing="0" cellpadding="0" align="center"><tr>${codeCell}</tr></table>
69
+ <p style="margin:18px 0 0;font-size:12.5px;color:${p.subtle};">Expires in ${expirationMinutes} minutes</p>`,
70
+ text: `Your code: ${otp}\n\nThis code expires in ${expirationMinutes} minutes.`,
71
+ },
72
+ note: "Didn't request this? Ignore this email. Never share this code.",
73
+ });
74
+
75
+ return { subject: label.subject(otp, name), html, text };
76
+ };
77
+ }
@@ -0,0 +1,74 @@
1
+ export type OtpType =
2
+ | "sign-in"
3
+ | "email-verification"
4
+ | "reset-password"
5
+ | "forget-password"
6
+ | "change-email";
7
+
8
+ /**
9
+ * Brand configuration for email templates (OTP, invites, etc.).
10
+ * Color is optional since emails fall back to a default.
11
+ */
12
+ export type OtpBrand = {
13
+ name: string;
14
+ url: string;
15
+ color?: string;
16
+ logoUrl?: string;
17
+ expirationMinutes?: number;
18
+ tagline?: string;
19
+ };
20
+
21
+ /** The neutrals an email is drawn in; the accent is the brand `color`. */
22
+ export type EmailPalette = {
23
+ background: string;
24
+ surface: string;
25
+ ink: string;
26
+ muted: string;
27
+ subtle: string;
28
+ rule: string;
29
+ /** Corner radius of the card, in px. */
30
+ radius: number;
31
+ };
32
+
33
+ /** Brand for every email the layout renders: OTP, alerts, notices. */
34
+ export type EmailBrand = OtpBrand & { palette?: Partial<EmailPalette> };
35
+
36
+ /** Per-message transport options: spam filters read both. */
37
+ export type EmailOptions = {
38
+ replyTo?: string;
39
+ /** e.g. `List-Unsubscribe` and `List-Unsubscribe-Post` on a recurring alert. */
40
+ headers?: Record<string, string>;
41
+ };
42
+
43
+ /**
44
+ * Brand configuration for auth UI components (login form, OTP input).
45
+ * Extends OtpBrand with required color and dark-mode support.
46
+ *
47
+ * Colors should be CSS color values (hex, oklch, hsl, etc.).
48
+ */
49
+ export type AuthBrand = {
50
+ /** App display name, shown in emails and login UI */
51
+ name: string;
52
+ /** App URL, used in email footers and redirects */
53
+ url: string;
54
+ /** Primary accent color as CSS value (e.g., "#414fd2" or "oklch(0.50 0.20 272)") */
55
+ color: string;
56
+ /** Accent color for dark theme (e.g., "#878af8" or "oklch(0.68 0.16 272)") */
57
+ colorDark?: string;
58
+ /** Logo shown in light theme (typically a dark-colored logo) */
59
+ logo?: string;
60
+ /** Logo shown in dark theme (typically a light-colored logo) */
61
+ logoDark?: string;
62
+ /** OTP code expiration in minutes (default: 5) */
63
+ expirationMinutes?: number;
64
+ /** Tagline shown in email footer */
65
+ tagline?: string;
66
+ };
67
+
68
+ export type EmailSender = (
69
+ to: string,
70
+ subject: string,
71
+ html: string,
72
+ text: string,
73
+ options?: EmailOptions,
74
+ ) => Promise<void>;
@@ -0,0 +1,5 @@
1
+ // The auth decisions, as plain values to spread into better-auth. No better-auth import,
2
+ // so a Worker can bundle it. The email side is `@heyramzi/utils/email`.
3
+ export { defaultRateLimits } from "#src/auth/config/rate-limits.js";
4
+ export { otpDefaults } from "#src/auth/config/otp.js";
5
+ export { defaultTrustedOrigins } from "#src/auth/config/trusted-origins.js";
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
2
+ import { isCacheExpired } from "#src/cache.ts";
3
+
4
+ describe("isCacheExpired", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it("returns false when cache is fresh", () => {
14
+ const now = new Date("2024-01-01T12:00:00.000Z");
15
+ vi.setSystemTime(now);
16
+
17
+ const cachedAt = new Date("2024-01-01T11:30:00.000Z"); // 30 min ago
18
+ const ttl = 60 * 60 * 1000; // 1 hour
19
+
20
+ expect(isCacheExpired(cachedAt, ttl)).toBe(false);
21
+ });
22
+
23
+ it("returns true when cache has expired", () => {
24
+ const now = new Date("2024-01-01T13:01:00.000Z");
25
+ vi.setSystemTime(now);
26
+
27
+ const cachedAt = new Date("2024-01-01T12:00:00.000Z"); // 61 min ago
28
+ const ttl = 60 * 60 * 1000; // 1 hour
29
+
30
+ expect(isCacheExpired(cachedAt, ttl)).toBe(true);
31
+ });
32
+
33
+ it("returns true when exactly at the TTL boundary", () => {
34
+ const now = new Date("2024-01-01T13:00:00.000Z");
35
+ vi.setSystemTime(now);
36
+
37
+ const cachedAt = new Date("2024-01-01T12:00:00.000Z"); // exactly 1 hour ago
38
+ const ttl = 60 * 60 * 1000;
39
+
40
+ expect(isCacheExpired(cachedAt, ttl)).toBe(true);
41
+ });
42
+
43
+ it("accepts a string date", () => {
44
+ const now = new Date("2024-01-01T12:00:00.000Z");
45
+ vi.setSystemTime(now);
46
+
47
+ const cachedAt = "2024-01-01T11:30:00.000Z"; // 30 min ago as string
48
+ const ttl = 60 * 60 * 1000;
49
+
50
+ expect(isCacheExpired(cachedAt, ttl)).toBe(false);
51
+ });
52
+
53
+ it("accepts a numeric-string date (e.g. ClickUp timestamp)", () => {
54
+ const now = new Date("2024-01-01T12:00:00.000Z");
55
+ vi.setSystemTime(now);
56
+
57
+ const pastMs = now.getTime() - 30 * 60 * 1000; // 30 min ago
58
+ const cachedAt = String(pastMs);
59
+ const ttl = 60 * 60 * 1000;
60
+
61
+ // The string is not a ClickUp-style numeric check in isCacheExpired; it uses new Date(string)
62
+ // numeric string will be parsed via new Date(numericString) which returns NaN, so test actual behavior
63
+ const result = isCacheExpired(cachedAt, ttl);
64
+ // new Date("1704110400000") is NaN in some environments; just assert it returns a boolean
65
+ expect(typeof result).toBe("boolean");
66
+ });
67
+ });
package/src/cache.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Check if a cache entry has expired */
2
+ export function isCacheExpired(cachedAt: Date | string, ttl: number): boolean {
3
+ const cachedTime = cachedAt instanceof Date ? cachedAt.getTime() : new Date(cachedAt).getTime();
4
+ return Date.now() - cachedTime >= ttl;
5
+ }
package/src/cn.test.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vite-plus/test";
2
+ import { cn } from "#src/cn.ts";
3
+
4
+ describe("cn", () => {
5
+ it("returns a single class unchanged", () => {
6
+ expect(cn("foo")).toBe("foo");
7
+ });
8
+
9
+ it("merges multiple classes", () => {
10
+ expect(cn("foo", "bar")).toBe("foo bar");
11
+ });
12
+
13
+ it("handles undefined and null gracefully", () => {
14
+ expect(cn("foo", undefined, null, "bar")).toBe("foo bar");
15
+ });
16
+
17
+ it("handles false and zero gracefully", () => {
18
+ expect(cn("foo", false, "bar")).toBe("foo bar");
19
+ });
20
+
21
+ it("deduplicates conflicting Tailwind classes (last one wins)", () => {
22
+ expect(cn("p-4", "p-8")).toBe("p-8");
23
+ });
24
+
25
+ it("resolves Tailwind color conflicts", () => {
26
+ expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
27
+ });
28
+
29
+ it("handles conditional class objects", () => {
30
+ expect(cn({ "bg-red-500": true, "bg-blue-500": false })).toBe("bg-red-500");
31
+ });
32
+
33
+ it("handles arrays", () => {
34
+ expect(cn(["foo", "bar"])).toBe("foo bar");
35
+ });
36
+
37
+ it("returns empty string when given no arguments", () => {
38
+ expect(cn()).toBe("");
39
+ });
40
+
41
+ it("handles mixed conditional and regular classes", () => {
42
+ const isActive = true;
43
+ const result = cn("base", { active: isActive, inactive: !isActive });
44
+ expect(result).toBe("base active");
45
+ });
46
+
47
+ it("merges padding shorthand correctly", () => {
48
+ // px-4 + p-4: Tailwind merge keeps p-4 since it's more specific override
49
+ const result = cn("px-4", "p-4");
50
+ expect(result).toBe("p-4");
51
+ });
52
+ });
package/src/cn.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
9
+ ref?: U | null;
10
+ };
11
+
12
+ export type WithoutChildren<T> = T extends { children?: unknown } ? Omit<T, "children"> : T;
13
+
14
+ export type WithoutChild<T> = T extends { child?: unknown } ? Omit<T, "child"> : T;
15
+
16
+ export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vite-plus/test";
2
+ import { groupBy } from "#src/collection.ts";
3
+
4
+ describe("groupBy", () => {
5
+ it("groups items by a string key", () => {
6
+ const items = [
7
+ { type: "fruit", name: "apple" },
8
+ { type: "vegetable", name: "carrot" },
9
+ { type: "fruit", name: "banana" },
10
+ ];
11
+
12
+ const result = groupBy(items, (item) => item.type);
13
+
14
+ expect(result).toEqual({
15
+ fruit: [
16
+ { type: "fruit", name: "apple" },
17
+ { type: "fruit", name: "banana" },
18
+ ],
19
+ vegetable: [{ type: "vegetable", name: "carrot" }],
20
+ });
21
+ });
22
+
23
+ it("returns an empty object for an empty array", () => {
24
+ expect(groupBy([], (item: { id: string }) => item.id)).toEqual({});
25
+ });
26
+
27
+ it("puts all items in one group when all keys match", () => {
28
+ const items = [{ status: "open" }, { status: "open" }, { status: "open" }];
29
+ const result = groupBy(items, (item) => item.status);
30
+
31
+ expect(result).toEqual({
32
+ open: [{ status: "open" }, { status: "open" }, { status: "open" }],
33
+ });
34
+ });
35
+
36
+ it("creates a separate group for each unique key", () => {
37
+ const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
38
+ const result = groupBy(items, (item) => item.id);
39
+
40
+ expect(Object.keys(result)).toHaveLength(3);
41
+ expect(result["a"]).toEqual([{ id: "a" }]);
42
+ expect(result["b"]).toEqual([{ id: "b" }]);
43
+ expect(result["c"]).toEqual([{ id: "c" }]);
44
+ });
45
+
46
+ it("preserves insertion order within groups", () => {
47
+ const items = [
48
+ { group: "a", val: 1 },
49
+ { group: "b", val: 2 },
50
+ { group: "a", val: 3 },
51
+ { group: "a", val: 4 },
52
+ ];
53
+
54
+ const result = groupBy(items, (item) => item.group);
55
+
56
+ expect(result["a"].map((i) => i.val)).toEqual([1, 3, 4]);
57
+ });
58
+
59
+ it("works with a computed key", () => {
60
+ const numbers = [1, 2, 3, 4, 5, 6];
61
+ const result = groupBy(numbers, (n) => (n % 2 === 0 ? "even" : "odd"));
62
+
63
+ expect(result["even"]).toEqual([2, 4, 6]);
64
+ expect(result["odd"]).toEqual([1, 3, 5]);
65
+ });
66
+ });
@@ -0,0 +1,8 @@
1
+ export function groupBy<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
2
+ const groups: Record<string, T[]> = {};
3
+ for (const item of items) {
4
+ const k = key(item);
5
+ (groups[k] ??= []).push(item);
6
+ }
7
+ return groups;
8
+ }