@heyramzi/utils 1.12.0 → 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 (47) hide show
  1. package/dist/ai/image.js +220 -0
  2. package/dist/ai/models.js +261 -0
  3. package/dist/analytics/client.js +57 -0
  4. package/dist/analytics/server.js +54 -0
  5. package/dist/analytics/svelte.js +24 -0
  6. package/dist/analytics/types.js +0 -0
  7. package/dist/analytics/umami/protocol.js +35 -0
  8. package/dist/analytics/umami/reporting.js +189 -0
  9. package/dist/auth/config/otp.js +4 -0
  10. package/dist/auth/config/rate-limits.js +20 -0
  11. package/dist/auth/config/trusted-origins.js +15 -0
  12. package/dist/auth/email/index.js +3 -0
  13. package/dist/auth/email/layout.js +84 -0
  14. package/dist/auth/email/resend.js +30 -0
  15. package/dist/auth/email/template.js +57 -0
  16. package/dist/auth/email/types.js +0 -0
  17. package/dist/auth/index.js +3 -0
  18. package/dist/cache.js +4 -0
  19. package/dist/cn.js +5 -0
  20. package/dist/collection.js +8 -0
  21. package/dist/crypto.js +117 -0
  22. package/dist/error.js +15 -0
  23. package/dist/errors/sentry/index.js +15 -0
  24. package/dist/errors/sentry/protocol.js +130 -0
  25. package/dist/errors/sentry/sveltekit.js +145 -0
  26. package/dist/format.js +62 -0
  27. package/dist/grab.js +112 -0
  28. package/dist/index.js +10 -0
  29. package/dist/logger-types.js +0 -0
  30. package/dist/logger.js +22 -0
  31. package/dist/math.js +3 -0
  32. package/dist/og/fonts.js +46 -0
  33. package/dist/og/index.js +3 -0
  34. package/dist/og/markup.js +31 -0
  35. package/dist/og/renderer.js +29 -0
  36. package/dist/og/types.js +0 -0
  37. package/dist/result.js +26 -0
  38. package/dist/string.js +32 -0
  39. package/dist/testimonials/render-content.js +15 -0
  40. package/dist/testimonials/server.js +21 -0
  41. package/dist/testimonials/types.js +0 -0
  42. package/dist/timing.js +20 -0
  43. package/dist/vite.js +265 -0
  44. package/package.json +125 -27
  45. package/src/auth/email/template.ts +2 -2
  46. package/src/errors/sentry/sveltekit.test.ts +63 -2
  47. package/src/errors/sentry/sveltekit.ts +43 -5
@@ -0,0 +1,189 @@
1
+ const TOKEN_TTL_MS = 30 * 60 * 1000;
2
+ const WEBSITE_LIST_TTL_MS = 10 * 60 * 1000;
3
+ const PAGE_SIZE = 150;
4
+ export class UmamiReportingClient {
5
+ baseUrl;
6
+ username;
7
+ password;
8
+ apiKey;
9
+ sessionCookie;
10
+ teamId;
11
+ accessHeaders;
12
+ fetchImpl;
13
+ cachedToken = null;
14
+ tokenExpiry = 0;
15
+ cachedWebsites = null;
16
+ websitesExpiry = 0;
17
+ constructor(credentials){
18
+ this.baseUrl = credentials.apiUrl.replace(/\/+$/, "");
19
+ this.username = credentials.username;
20
+ this.password = credentials.password;
21
+ this.apiKey = credentials.apiKey;
22
+ this.sessionCookie = credentials.sessionCookie;
23
+ this.teamId = credentials.teamId;
24
+ this.accessHeaders = credentials.accessClientId && credentials.accessClientSecret ? {
25
+ "CF-Access-Client-Id": credentials.accessClientId,
26
+ "CF-Access-Client-Secret": credentials.accessClientSecret
27
+ } : {};
28
+ const injected = credentials.fetchImpl;
29
+ if (!injected && typeof globalThis.fetch !== "function") {
30
+ throw new Error("No fetch available. Pass fetchImpl to UmamiReportingClient.");
31
+ }
32
+ this.fetchImpl = injected ?? ((url, init)=>globalThis.fetch(url, init));
33
+ }
34
+ async getToken() {
35
+ if (this.cachedToken && Date.now() < this.tokenExpiry) return this.cachedToken;
36
+ const response = await this.fetchImpl(`${this.baseUrl}/api/auth/login`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ ...this.accessHeaders
41
+ },
42
+ body: JSON.stringify({
43
+ username: this.username,
44
+ email: this.username,
45
+ password: this.password
46
+ })
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`Umami auth failed: ${response.status} ${response.statusText}`);
50
+ }
51
+ const data = await response.json();
52
+ this.cachedToken = data.token;
53
+ this.tokenExpiry = Date.now() + TOKEN_TTL_MS;
54
+ return data.token;
55
+ }
56
+ async request(path, method = "GET", body) {
57
+ const url = this.apiKey ? `${this.baseUrl}${path.replace(/^\/api/, "")}` : `${this.baseUrl}${path}`;
58
+ const auth = this.apiKey ? {
59
+ "x-umami-api-key": this.apiKey
60
+ } : this.sessionCookie ? {
61
+ Cookie: this.sessionCookie
62
+ } : {
63
+ Authorization: `Bearer ${await this.getToken()}`
64
+ };
65
+ const response = await this.fetchImpl(url, {
66
+ method,
67
+ headers: {
68
+ ...auth,
69
+ "Content-Type": "application/json",
70
+ ...this.accessHeaders
71
+ },
72
+ body: body === undefined ? undefined : JSON.stringify(body)
73
+ });
74
+ if (response.status === 401) {
75
+ this.cachedToken = null;
76
+ this.tokenExpiry = 0;
77
+ throw new Error("Umami auth token expired");
78
+ }
79
+ if (!response.ok) {
80
+ throw new Error(`Umami API error: ${response.status} ${response.statusText} (${path})`);
81
+ }
82
+ if (method === "DELETE") return undefined;
83
+ return await response.json();
84
+ }
85
+ async getWebsites() {
86
+ if (this.cachedWebsites && Date.now() < this.websitesExpiry) return this.cachedWebsites;
87
+ const personal = await this.request(`/api/websites?pageSize=${PAGE_SIZE}`);
88
+ let teamSites = [];
89
+ if (this.teamId) {
90
+ const team = await this.request(`/api/teams/${this.teamId}/websites?pageSize=${PAGE_SIZE}`);
91
+ teamSites = team.data ?? [];
92
+ }
93
+ const seen = new Set();
94
+ const websites = [];
95
+ for (const site of [
96
+ ...personal.data ?? [],
97
+ ...teamSites
98
+ ]){
99
+ if (seen.has(site.id)) continue;
100
+ seen.add(site.id);
101
+ websites.push(site);
102
+ }
103
+ this.cachedWebsites = websites;
104
+ this.websitesExpiry = Date.now() + WEBSITE_LIST_TTL_MS;
105
+ return websites;
106
+ }
107
+ async createWebsite(input) {
108
+ const site = await this.request("/api/websites", "POST", {
109
+ name: input.name,
110
+ domain: input.domain,
111
+ ...input.teamId ? {
112
+ teamId: input.teamId
113
+ } : {}
114
+ });
115
+ this.cachedWebsites = null;
116
+ this.websitesExpiry = 0;
117
+ return site;
118
+ }
119
+ async getReports(websiteId) {
120
+ const page = await this.request(`/api/reports?websiteId=${websiteId}&pageSize=200`);
121
+ return Array.isArray(page) ? page : page.data;
122
+ }
123
+ async createReport(input) {
124
+ return this.request("/api/reports", "POST", input);
125
+ }
126
+ async deleteWebsite(websiteId) {
127
+ await this.request(`/api/websites/${websiteId}`, "DELETE");
128
+ this.cachedWebsites = null;
129
+ this.websitesExpiry = 0;
130
+ }
131
+ async getStats(websiteId, startAt, endAt) {
132
+ return this.request(`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`);
133
+ }
134
+ async getDateRange(websiteId) {
135
+ return this.request(`/api/websites/${websiteId}/daterange`);
136
+ }
137
+ async getActive(websiteId) {
138
+ const data = await this.request(`/api/websites/${websiteId}/active`);
139
+ return data.visitors ?? 0;
140
+ }
141
+ async getMetrics(websiteId, type, startAt, endAt, limit = 20) {
142
+ return this.request(`/api/websites/${websiteId}/metrics?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`);
143
+ }
144
+ async getExpandedMetrics(websiteId, type, startAt, endAt, limit = 20) {
145
+ const raw = await this.request(`/api/websites/${websiteId}/metrics/expanded?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`);
146
+ return raw.map((entry)=>({
147
+ name: entry.name,
148
+ pageviews: Number(entry.pageviews),
149
+ visitors: entry.visitors,
150
+ visits: entry.visits,
151
+ bounces: entry.bounces,
152
+ totaltime: Number(entry.totaltime)
153
+ }));
154
+ }
155
+ async getPageviews(websiteId, startAt, endAt, unit = "day", timezone = "UTC") {
156
+ return this.request(`/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + `&timezone=${encodeURIComponent(timezone)}`);
157
+ }
158
+ async getEvents(websiteId, startAt, endAt, pageSize = 50) {
159
+ return this.request(`/api/websites/${websiteId}/events?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`);
160
+ }
161
+ async getEventSeries(websiteId, startAt, endAt, unit = "day", timezone = "UTC") {
162
+ return this.request(`/api/websites/${websiteId}/events/series?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + `&timezone=${encodeURIComponent(timezone)}`);
163
+ }
164
+ async getEventDataStats(websiteId, startAt, endAt) {
165
+ return this.request(`/api/websites/${websiteId}/event-data/stats?startAt=${startAt}&endAt=${endAt}`);
166
+ }
167
+ async getEventDataValues(websiteId, startAt, endAt, event, field) {
168
+ const params = new URLSearchParams({
169
+ startAt: String(startAt),
170
+ endAt: String(endAt),
171
+ event,
172
+ propertyName: field
173
+ });
174
+ return this.request(`/api/websites/${websiteId}/event-data/values?${params.toString()}`);
175
+ }
176
+ async getSessions(websiteId, startAt, endAt, pageSize = 50) {
177
+ return this.request(`/api/websites/${websiteId}/sessions?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`);
178
+ }
179
+ async resolveSiteId(siteArg) {
180
+ if (/^[0-9a-f-]{36}$/i.test(siteArg)) return siteArg;
181
+ const websites = await this.getWebsites();
182
+ const lower = siteArg.toLowerCase();
183
+ const byDomain = websites.find((w)=>w.domain.toLowerCase() === lower);
184
+ if (byDomain) return byDomain.id;
185
+ const byPartial = websites.find((w)=>w.domain.toLowerCase().includes(lower) || w.name.toLowerCase().includes(lower));
186
+ if (byPartial) return byPartial.id;
187
+ throw new Error(`No website found matching "${siteArg}". Run \`umami websites\` to see available sites.`);
188
+ }
189
+ }
@@ -0,0 +1,4 @@
1
+ export const otpDefaults = {
2
+ otpLength: 6,
3
+ expiresIn: 300
4
+ };
@@ -0,0 +1,20 @@
1
+ export const defaultRateLimits = {
2
+ enabled: true,
3
+ storage: "memory",
4
+ window: 60,
5
+ max: 100,
6
+ customRules: {
7
+ "/email-otp/send-verification-otp": {
8
+ window: 60,
9
+ max: 3
10
+ },
11
+ "/sign-in/email-otp": {
12
+ window: 10,
13
+ max: 3
14
+ },
15
+ "/email-otp/verify-email": {
16
+ window: 10,
17
+ max: 3
18
+ }
19
+ }
20
+ };
@@ -0,0 +1,15 @@
1
+ export function defaultTrustedOrigins(siteUrl) {
2
+ const origins = new Set([
3
+ siteUrl
4
+ ]);
5
+ const extra = process.env.TRUSTED_ORIGINS;
6
+ if (extra) {
7
+ for (const origin of extra.split(",")){
8
+ const trimmed = origin.trim();
9
+ if (trimmed) origins.add(trimmed);
10
+ }
11
+ }
12
+ return [
13
+ ...origins
14
+ ];
15
+ }
@@ -0,0 +1,3 @@
1
+ export { buildOtpEmail } from "./template.js";
2
+ export { createResendSender } from "./resend.js";
3
+ export { renderEmail, resolvePalette, escapeHtml, MONO_STACK } from "./layout.js";
@@ -0,0 +1,84 @@
1
+ const DEFAULT_PALETTE = {
2
+ background: "#f4f4f5",
3
+ surface: "#ffffff",
4
+ ink: "#18181b",
5
+ muted: "#71717a",
6
+ subtle: "#a1a1aa",
7
+ rule: "#ececec",
8
+ radius: 14
9
+ };
10
+ export const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
11
+ export const MONO_STACK = "'SF Mono',Monaco,'Cascadia Code','Roboto Mono',Consolas,'Courier New',monospace";
12
+ export function resolvePalette(brand) {
13
+ return {
14
+ ...DEFAULT_PALETTE,
15
+ ...brand.palette
16
+ };
17
+ }
18
+ export function escapeHtml(value) {
19
+ return value.replace(/[&<>"']/g, (c)=>`&#${c.charCodeAt(0)};`);
20
+ }
21
+ export function hexToRgb(hex) {
22
+ const match = /^#?([a-f\d]{3}|[a-f\d]{6})$/i.exec(hex.trim());
23
+ if (!match) return null;
24
+ const full = match[1].length === 3 ? [
25
+ ...match[1]
26
+ ].map((c)=>c + c).join("") : match[1];
27
+ const n = Number.parseInt(full, 16);
28
+ return [
29
+ n >> 16 & 255,
30
+ n >> 8 & 255,
31
+ n & 255
32
+ ];
33
+ }
34
+ export function renderEmail(brand, content) {
35
+ const { name, url, color = "#6366f1", logoUrl, tagline } = brand;
36
+ const p = resolvePalette(brand);
37
+ const align = content.align ?? "center";
38
+ const domain = url.replace(/^https?:\/\//, "").replace(/\/$/, "");
39
+ const year = new Date().getFullYear();
40
+ const row = (html, padding, rowAlign = align)=>`<tr><td align="${rowAlign}" style="padding:${padding};text-align:${rowAlign};">${html}</td></tr>`;
41
+ const brandRow = row(`<table role="presentation" cellspacing="0" cellpadding="0"${align === "center" ? ' align="center"' : ""}><tr>
42
+ ${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>` : ""}
43
+ <td style="vertical-align:middle;"><span style="font-size:15px;font-weight:700;color:${p.ink};letter-spacing:-0.01em;">${escapeHtml(name)}</span></td>
44
+ </tr></table>`, "32px 32px 20px");
45
+ const action = content.action ? row(`<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;">
46
+ <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>
47
+ </td></tr></table>`, "4px 32px 24px") : "";
48
+ const html = `<!DOCTYPE html>
49
+ <html lang="en">
50
+ <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>
51
+ <body style="margin:0;padding:0;background-color:${p.background};font-family:${FONT_STACK};color:${p.ink};-webkit-font-smoothing:antialiased;">
52
+ <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(content.preview)}</div>
53
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:${p.background};">
54
+ <tr><td align="center" style="padding:40px 16px 32px;">
55
+ <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};">
56
+ ${brandRow}
57
+ ${row(`<h1 style="margin:0;font-size:18px;line-height:1.35;font-weight:600;color:${p.ink};">${escapeHtml(content.heading)}</h1>`, "0 32px")}
58
+ ${content.intro ? row(`<p style="margin:0;font-size:14px;line-height:1.55;color:${p.muted};">${escapeHtml(content.intro)}</p>`, "8px 32px 0") : ""}
59
+ ${content.body ? row(content.body.html, "24px 32px") : row("", "12px 32px 0")}
60
+ ${action}
61
+ ${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")}
62
+ </table>
63
+ <table role="presentation" cellspacing="0" cellpadding="0" style="max-width:520px;width:100%;">
64
+ <tr><td align="center" style="padding:18px 32px 0;">
65
+ <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>
66
+ </td></tr>
67
+ </table>
68
+ </td></tr>
69
+ </table>
70
+ </body>
71
+ </html>`;
72
+ const text = [
73
+ content.heading,
74
+ content.intro,
75
+ content.body?.text,
76
+ content.action ? `${content.action.label}: ${content.action.url}` : undefined,
77
+ content.note,
78
+ `---\n${name} · ${domain}`
79
+ ].filter((part)=>Boolean(part)).join("\n\n");
80
+ return {
81
+ html,
82
+ text
83
+ };
84
+ }
@@ -0,0 +1,30 @@
1
+ export function createResendSender(apiKey, from) {
2
+ return async (to, subject, html, text, options = {})=>{
3
+ const res = await fetch("https://api.resend.com/emails", {
4
+ method: "POST",
5
+ headers: {
6
+ Authorization: `Bearer ${apiKey}`,
7
+ "Content-Type": "application/json"
8
+ },
9
+ body: JSON.stringify({
10
+ from,
11
+ to: [
12
+ to
13
+ ],
14
+ subject,
15
+ html,
16
+ text,
17
+ ...options.replyTo ? {
18
+ reply_to: options.replyTo
19
+ } : {},
20
+ ...options.headers ? {
21
+ headers: options.headers
22
+ } : {}
23
+ })
24
+ });
25
+ if (!res.ok) {
26
+ const body = await res.text().catch(()=>"");
27
+ throw new Error(`Resend failed (${res.status}): ${body}`);
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,57 @@
1
+ import { escapeHtml, hexToRgb, FONT_STACK, renderEmail, resolvePalette } from "./layout.js";
2
+ const LABELS = {
3
+ "sign-in": {
4
+ subject: (_otp, app)=>`${app} sign-in code`,
5
+ heading: "Sign-in code",
6
+ instruction: "Enter this code to sign in to your account."
7
+ },
8
+ "email-verification": {
9
+ subject: (_otp, app)=>`Verify your ${app} email`,
10
+ heading: "Verify your email",
11
+ instruction: "Enter this code to verify your email address."
12
+ },
13
+ "reset-password": {
14
+ subject: (_otp, app)=>`Reset your ${app} password`,
15
+ heading: "Reset your password",
16
+ instruction: "Enter this code to reset your password."
17
+ },
18
+ "forget-password": {
19
+ subject: (_otp, app)=>`Reset your ${app} password`,
20
+ heading: "Reset your password",
21
+ instruction: "Enter this code to reset your password."
22
+ },
23
+ "change-email": {
24
+ subject: (_otp, app)=>`Confirm your new ${app} email`,
25
+ heading: "Confirm email change",
26
+ instruction: "Enter this code to confirm your new email address."
27
+ }
28
+ };
29
+ export function buildOtpEmail(brand) {
30
+ return (otp, type = "sign-in")=>{
31
+ const { name, color = "#6366f1", expirationMinutes = 5 } = brand;
32
+ const p = resolvePalette(brand);
33
+ const label = LABELS[type];
34
+ const rgb = hexToRgb(color);
35
+ const cellBorder = rgb ? `rgba(${rgb.join(",")},0.35)` : color;
36
+ const cellBg = rgb ? `rgba(${rgb.join(",")},0.07)` : p.background;
37
+ 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};">
38
+ <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>
39
+ </td>`;
40
+ const { html, text } = renderEmail(brand, {
41
+ preview: `${otp}: ${label.instruction}`,
42
+ heading: label.heading,
43
+ intro: label.instruction,
44
+ body: {
45
+ html: `<table role="presentation" cellspacing="0" cellpadding="0" align="center"><tr>${codeCell}</tr></table>
46
+ <p style="margin:18px 0 0;font-size:12.5px;color:${p.subtle};">Expires in ${expirationMinutes} minutes</p>`,
47
+ text: `Your code: ${otp}\n\nThis code expires in ${expirationMinutes} minutes.`
48
+ },
49
+ note: "Didn't request this? Ignore this email. Never share this code."
50
+ });
51
+ return {
52
+ subject: label.subject(otp, name),
53
+ html,
54
+ text
55
+ };
56
+ };
57
+ }
File without changes
@@ -0,0 +1,3 @@
1
+ export { defaultRateLimits } from "./config/rate-limits.js";
2
+ export { otpDefaults } from "./config/otp.js";
3
+ export { defaultTrustedOrigins } from "./config/trusted-origins.js";
package/dist/cache.js ADDED
@@ -0,0 +1,4 @@
1
+ export function isCacheExpired(cachedAt, ttl) {
2
+ const cachedTime = cachedAt instanceof Date ? cachedAt.getTime() : new Date(cachedAt).getTime();
3
+ return Date.now() - cachedTime >= ttl;
4
+ }
package/dist/cn.js ADDED
@@ -0,0 +1,5 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+ export function cn(...inputs) {
4
+ return twMerge(clsx(inputs));
5
+ }
@@ -0,0 +1,8 @@
1
+ export function groupBy(items, key) {
2
+ const groups = {};
3
+ for (const item of items){
4
+ const k = key(item);
5
+ (groups[k] ??= []).push(item);
6
+ }
7
+ return groups;
8
+ }
package/dist/crypto.js ADDED
@@ -0,0 +1,117 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
3
+ const ALGORITHM = "aes-256-gcm";
4
+ const AUTH_TAG_BYTES = 16;
5
+ const IV_BYTES = 12;
6
+ const KEY_BYTES = 32;
7
+ const KEY_ENV = "HEYRAMZI_UTILS_CRYPTO_KEY";
8
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
9
+ const HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
10
+ function ensureServerEnv() {
11
+ const runtimeProcess = globalThis.process;
12
+ if (!runtimeProcess?.env) {
13
+ throw new Error("[crypto] process.env is unavailable. @heyramzi/utils/crypto is server-only.");
14
+ }
15
+ return runtimeProcess.env;
16
+ }
17
+ function decodeBase64(value, label) {
18
+ const normalized = value.trim();
19
+ if (!normalized || normalized.length % 4 !== 0 || !BASE64_PATTERN.test(normalized)) {
20
+ throw new Error(`[crypto] ${label} must be valid base64.`);
21
+ }
22
+ const bytes = Buffer.from(normalized, "base64");
23
+ if (bytes.toString("base64") !== normalized) {
24
+ throw new Error(`[crypto] ${label} must be valid base64.`);
25
+ }
26
+ return bytes;
27
+ }
28
+ function decodeSecretKey(rawValue, label) {
29
+ if (!rawValue) {
30
+ throw new Error(`[crypto] Missing ${label}. Set a 32-byte key encoded as base64 (recommended) or 64-character hex.`);
31
+ }
32
+ if (HEX_PATTERN.test(rawValue)) {
33
+ return Buffer.from(rawValue, "hex");
34
+ }
35
+ const key = decodeBase64(rawValue, label);
36
+ if (key.length !== KEY_BYTES) {
37
+ throw new Error(`[crypto] ${label} must decode to exactly ${KEY_BYTES} bytes for AES-256-GCM.`);
38
+ }
39
+ return key;
40
+ }
41
+ function getSecretKey() {
42
+ const env = ensureServerEnv();
43
+ const rawValue = env[KEY_ENV]?.trim();
44
+ return decodeSecretKey(rawValue ?? "", KEY_ENV);
45
+ }
46
+ function encryptWithKey(plaintext, key) {
47
+ const iv = randomBytes(IV_BYTES);
48
+ const cipher = createCipheriv(ALGORITHM, key, iv);
49
+ const ciphertext = Buffer.concat([
50
+ cipher.update(plaintext, "utf8"),
51
+ cipher.final(),
52
+ cipher.getAuthTag()
53
+ ]);
54
+ return {
55
+ ciphertext: ciphertext.toString("base64"),
56
+ iv: iv.toString("base64")
57
+ };
58
+ }
59
+ function decryptWithKey(ciphertext, iv, key) {
60
+ const ivBytes = decodeBase64(iv, "iv");
61
+ if (ivBytes.length !== IV_BYTES) {
62
+ throw new Error(`[crypto] iv must decode to exactly ${IV_BYTES} bytes.`);
63
+ }
64
+ const payload = decodeBase64(ciphertext, "ciphertext");
65
+ if (payload.length < AUTH_TAG_BYTES) {
66
+ throw new Error("[crypto] ciphertext payload is invalid.");
67
+ }
68
+ const encrypted = payload.subarray(0, -AUTH_TAG_BYTES);
69
+ const authTag = payload.subarray(-AUTH_TAG_BYTES);
70
+ try {
71
+ const decipher = createDecipheriv(ALGORITHM, key, ivBytes);
72
+ decipher.setAuthTag(authTag);
73
+ return Buffer.concat([
74
+ decipher.update(encrypted),
75
+ decipher.final()
76
+ ]).toString("utf8");
77
+ } catch (error) {
78
+ throw new Error("[crypto] Failed to decrypt secret. The payload may be tampered with or invalid.", {
79
+ cause: error
80
+ });
81
+ }
82
+ }
83
+ export function encryptSecret(plaintext) {
84
+ return encryptWithKey(plaintext, getSecretKey());
85
+ }
86
+ export function decryptSecret(ciphertext, iv) {
87
+ return decryptWithKey(ciphertext, iv, getSecretKey());
88
+ }
89
+ export function encryptSecretWithKey(plaintext, key) {
90
+ return encryptWithKey(plaintext, decodeSecretKey(key.trim(), "key"));
91
+ }
92
+ export function decryptSecretWithKey(ciphertext, iv, key) {
93
+ return decryptWithKey(ciphertext, iv, decodeSecretKey(key.trim(), "key"));
94
+ }
95
+ export function packSecret(secret) {
96
+ const ivBytes = decodeBase64(secret.iv, "iv");
97
+ if (ivBytes.length !== IV_BYTES) {
98
+ throw new Error(`[crypto] iv must decode to exactly ${IV_BYTES} bytes.`);
99
+ }
100
+ const ciphertextBytes = decodeBase64(secret.ciphertext, "ciphertext");
101
+ return Buffer.concat([
102
+ ivBytes,
103
+ ciphertextBytes
104
+ ]).toString("base64");
105
+ }
106
+ export function unpackSecret(payload) {
107
+ const packed = decodeBase64(payload, "payload");
108
+ if (packed.length < IV_BYTES + AUTH_TAG_BYTES) {
109
+ throw new Error("[crypto] packed payload is invalid.");
110
+ }
111
+ const iv = packed.subarray(0, IV_BYTES);
112
+ const ciphertext = packed.subarray(IV_BYTES);
113
+ return {
114
+ ciphertext: ciphertext.toString("base64"),
115
+ iv: iv.toString("base64")
116
+ };
117
+ }
package/dist/error.js ADDED
@@ -0,0 +1,15 @@
1
+ export async function fetchApi(url, options) {
2
+ const response = await fetch(url, {
3
+ headers: {
4
+ "Content-Type": "application/json",
5
+ ...options?.headers
6
+ },
7
+ ...options
8
+ });
9
+ const data = await response.json();
10
+ if (!response.ok) {
11
+ const message = data?.error ?? `Request failed (${response.status})`;
12
+ throw new Error(message);
13
+ }
14
+ return data;
15
+ }
@@ -0,0 +1,15 @@
1
+ import { buildErrorRequest } from "./protocol.js";
2
+ export { buildErrorRequest, parseDsn, parseStack } from "./protocol.js";
3
+ export async function captureError(input) {
4
+ const req = buildErrorRequest(input);
5
+ if (!req) return;
6
+ try {
7
+ await fetch(req.url, {
8
+ method: "POST",
9
+ headers: req.headers,
10
+ body: req.body
11
+ });
12
+ } catch (err) {
13
+ console.error("[errors] Sentry capture failed:", err);
14
+ }
15
+ }