@zeyolabs/kit 0.0.0-stage → 2026.11.6

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 (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,338 @@
1
+ import {
2
+ type CalendarDate,
3
+ DateFormatter,
4
+ fromDate,
5
+ getDayOfWeek,
6
+ getLocalTimeZone,
7
+ now,
8
+ parseDate,
9
+ type Time,
10
+ toCalendarDate,
11
+ today,
12
+ toTime,
13
+ toZoned,
14
+ type ZonedDateTime,
15
+ } from '@internationalized/date';
16
+
17
+ const DEFAULT_LOCALE = 'en-GB';
18
+
19
+ // Day of the week
20
+
21
+ export const Days = [
22
+ 'Monday',
23
+ 'Tuesday',
24
+ 'Wednesday',
25
+ 'Thursday',
26
+ 'Friday',
27
+ 'Saturday',
28
+ 'Sunday',
29
+ ] as const;
30
+ export type Day = (typeof Days)[number];
31
+
32
+ export const DayIndex: Record<Day, number> = Object.fromEntries(
33
+ Days.map((day, index) => [day, index])
34
+ ) as Record<Day, number>;
35
+
36
+ // Date functions local to a timezone
37
+ export class LocalDateF {
38
+ constructor(private readonly timezone: string) {}
39
+
40
+ /** @returns The current date and time. */
41
+ now(): ZonedDateTime {
42
+ return now(this.timezone);
43
+ }
44
+
45
+ /** @returns The current date. */
46
+ today(): CalendarDate {
47
+ return today(this.timezone);
48
+ }
49
+ }
50
+
51
+ export const SERVER_TIME_ZONE = 'Europe/London';
52
+ export const SERVER_LOCALE = 'en-GB';
53
+
54
+ // Date functions local to server time zone
55
+ export const ServerDt = new LocalDateF(SERVER_TIME_ZONE);
56
+
57
+ /** Gets the day index of the date */
58
+ function getDayIndexOfDate(date: CalendarDate): number {
59
+ // Always start 0 on Monday
60
+ return getDayOfWeek(date, DEFAULT_LOCALE, 'mon');
61
+ }
62
+
63
+ /**
64
+ * Gets the day of the week for a given date.
65
+ * @param date - The date to get the day of the week for.
66
+ * @returns The day of the week
67
+ */
68
+ function getDayOfDate(date: CalendarDate): Day {
69
+ return Days[getDayIndexOfDate(date)];
70
+ }
71
+
72
+ /* Formatting */
73
+
74
+ /* Day of the week */
75
+
76
+ /**
77
+ * Formats a day of the week.
78
+ * @param day - The day of the week to format.
79
+ * @example "Monday" -> "Mon"
80
+ * @returns Formatted string of the day of the week.
81
+ */
82
+ function formatDayShort(day: Day): string {
83
+ // Use the first three letters of the day
84
+ return day.slice(0, 3);
85
+ }
86
+
87
+ /* Calendar Dates */
88
+
89
+ const FullDateFormatter = new DateFormatter(DEFAULT_LOCALE, {
90
+ day: 'numeric',
91
+ month: 'short',
92
+ year: 'numeric',
93
+ });
94
+
95
+ const ShortDateFormatter = new DateFormatter(DEFAULT_LOCALE, {
96
+ day: 'numeric',
97
+ month: 'short',
98
+ });
99
+
100
+ const NumFormatter = new DateFormatter(DEFAULT_LOCALE, {
101
+ day: '2-digit',
102
+ month: '2-digit',
103
+ year: 'numeric',
104
+ });
105
+
106
+ /**
107
+ * @param date The CalendarDate object to format.
108
+ * @returns The formatted date string in dd/mm/yyyy format.
109
+ * @example "05/10/2023"
110
+ */
111
+ function formatDateNum(date: CalendarDate): string {
112
+ return formatDate(date, NumFormatter);
113
+ }
114
+
115
+ /**
116
+ * Formats a full date and time.
117
+ * @param datetime The ZonedDateTime object to format.
118
+ * @returns The formatted date and time string.
119
+ * @example "05/10/2023 14:30:00"
120
+ */
121
+ function formatAbsolute(datetime: ZonedDateTime): string {
122
+ const date = toCalendarDate(datetime);
123
+ const time = toTime(datetime);
124
+ return `${formatDateNum(date)} ${formatTimeFull(time)}`;
125
+ }
126
+
127
+ /**
128
+ * @param date The CalendarDate object to format.
129
+ * @returns The formatted date string.
130
+ * @example "5 Oct 2023"
131
+ */
132
+ function formatDateFull(date: CalendarDate) {
133
+ return formatDate(date, FullDateFormatter);
134
+ }
135
+
136
+ /**
137
+ * ISO format
138
+ * @param date The CalendarDate object to format.
139
+ * @returns The formatted date string in YYYY-MM-DD format.
140
+ * @example "2023-10-05"
141
+ */
142
+ function formatDateISO(date: CalendarDate): string {
143
+ return date.toString();
144
+ }
145
+
146
+ /**
147
+ * @param date The CalendarDate object to format.
148
+ * @returns string of date in shortened format
149
+ * @example "Oct 5"
150
+ */
151
+ function formatDateShort(date: CalendarDate) {
152
+ return formatDate(date, ShortDateFormatter);
153
+ }
154
+
155
+ /* Times */
156
+
157
+ /**
158
+ * Gives time in HH:MM format
159
+ * @param time
160
+ * @returns string of time in that format
161
+ */
162
+ function formatTime(time: Time): string {
163
+ const hours = padNum(time.hour, 2);
164
+ const minutes = padNum(time.minute, 2);
165
+ return `${hours}:${minutes}`;
166
+ }
167
+
168
+ /**
169
+ * Gives time in HH:MM:SS format
170
+ * @param time
171
+ * @returns string of time in that format
172
+ */
173
+ function formatTimeFull(time: Time): string {
174
+ const hours = padNum(time.hour, 2);
175
+ const minutes = padNum(time.minute, 2);
176
+ const seconds = padNum(time.second, 2);
177
+ return `${hours}:${minutes}:${seconds}`;
178
+ }
179
+
180
+ /* Helpers */
181
+
182
+ function formatDate(date: CalendarDate, formatter: DateFormatter): string {
183
+ const nativeDate = date.toDate(getLocalTimeZone());
184
+ return formatter.format(nativeDate);
185
+ }
186
+
187
+ function padNum(num: number, len: number): string {
188
+ if (isNaN(num)) return '0'.repeat(len);
189
+
190
+ return num.toString().padStart(len, '0');
191
+ }
192
+
193
+ // An absolute instant re-zoned into a viewer's timezone. Without `timezone`, call from client
194
+ // code only: `getLocalTimeZone()` reads the current runtime's zone, so server-side it resolves to
195
+ // the server zone. The apps that display these are `ssr: false`, so that default always runs in
196
+ // the browser. Server-rendered content with no browser to detect from (e.g. an email) must pass
197
+ // the viewer's stored `user.timezone` explicitly.
198
+ const inViewerZone = (zdt: ZonedDateTime, timezone?: string): ZonedDateTime =>
199
+ fromDate(zdt.toDate(), timezone ?? getLocalTimeZone());
200
+
201
+ /** HH:MM in the viewer's timezone (stored `timezone`, else the browser's local zone). */
202
+ const formatLocalTime = (zdt: ZonedDateTime, timezone?: string): string =>
203
+ formatTime(toTime(inViewerZone(zdt, timezone)));
204
+
205
+ /** Full date (e.g. "5 Oct 2023") in the viewer's timezone (stored `timezone`, else the browser's local zone). */
206
+ const formatLocalDateFull = (zdt: ZonedDateTime, timezone?: string): string =>
207
+ formatDateFull(toCalendarDate(inViewerZone(zdt, timezone)));
208
+
209
+ /** Numeric date (dd/mm/yyyy) in the viewer's local timezone. */
210
+ const formatLocalDateNum = (zdt: ZonedDateTime): string =>
211
+ formatDateNum(toCalendarDate(inViewerZone(zdt)));
212
+
213
+ /** Numeric date + HH:MM:SS (e.g. "05/10/2023 14:30:00") in the viewer's timezone (stored
214
+ * `timezone`, else the browser's local zone). */
215
+ const formatLocalDateTime = (zdt: ZonedDateTime, timezone?: string): string =>
216
+ formatAbsolute(inViewerZone(zdt, timezone));
217
+
218
+ /** Whole days from `zdt` (a past instant) to today, in the viewer's timezone (clamped ≥ 0). */
219
+ const daysSince = (zdt: ZonedDateTime): number =>
220
+ Math.max(0, today(getLocalTimeZone()).compare(toCalendarDate(inViewerZone(zdt))));
221
+
222
+ /** Whole days from today to `zdt` (a future instant), in the viewer's timezone (clamped ≥ 0). */
223
+ const daysUntil = (zdt: ZonedDateTime): number =>
224
+ Math.max(0, toCalendarDate(inViewerZone(zdt)).compare(today(getLocalTimeZone())));
225
+
226
+ /** Full local date + whole days since the instant, for a "started/created on {date} · {daysAgo}d ago" UI. */
227
+ const formatDateWithDaysAgo = (zdt: ZonedDateTime): { date: string; daysAgo: number } => ({
228
+ date: formatLocalDateFull(zdt),
229
+ daysAgo: daysSince(zdt),
230
+ });
231
+
232
+ /** `from`, pulled forward so the inclusive `from`→`to` range spans at most `maxDays` days.
233
+ * A range already within the limit comes back unchanged. */
234
+ const clampSpanStart = (from: CalendarDate, to: CalendarDate, maxDays: number): CalendarDate =>
235
+ to.compare(from) > maxDays ? to.subtract({ days: maxDays }) : from;
236
+
237
+ /** Short weekday label for a date, e.g. "Mon". */
238
+ const weekdayShort = (date: CalendarDate): string => formatDayShort(getDayOfDate(date));
239
+
240
+ /** A CalendarDate as a short weekday + day-of-month, e.g. "Mon 31". */
241
+ const formatWeekdayDay = (date: CalendarDate): string => `${weekdayShort(date)} ${date.day}`;
242
+
243
+ /** A ClickHouse DateTime, returned as epoch seconds (the `unix_timestamp` output format), as a ZonedDateTime. */
244
+ const zonedFromUnixSeconds = (seconds: number): ZonedDateTime =>
245
+ fromDate(new Date(seconds * 1000), SERVER_TIME_ZONE);
246
+
247
+ /** A raw Postgres `timestamptz` string, as returned by a raw `ctx.db.execute(sql\`...\`)` select
248
+ * (bypassing the `absoluteDateTime` custom type's own driver conversion), as a ZonedDateTime. */
249
+ const zonedFromPgTimestamp = (s: string): ZonedDateTime => fromDate(new Date(s), SERVER_TIME_ZONE);
250
+
251
+ /** Domain → wire: a ZonedDateTime as epoch seconds, for CH inserts / query params only. */
252
+ const toUnixSeconds = (zdt: ZonedDateTime): number => Math.floor(zdt.toDate().getTime() / 1000);
253
+
254
+ /** Domain → wire: a CalendarDate at server-tz start-of-day, as epoch seconds (CH DateTime query param). */
255
+ const dayStartUnix = (date: CalendarDate): number => toUnixSeconds(toZoned(date, SERVER_TIME_ZONE));
256
+
257
+ /** Epoch milliseconds (e.g. Gmail `internalDate`) as a ZonedDateTime. */
258
+ const zonedFromUnixMillis = (millis: number): ZonedDateTime =>
259
+ fromDate(new Date(millis), SERVER_TIME_ZONE);
260
+
261
+ /** Domain → wire: a ZonedDateTime as epoch milliseconds (e.g. an HTTP JSON boundary). */
262
+ const toUnixMillis = (zdt: ZonedDateTime): number => zdt.toDate().getTime();
263
+
264
+ /**
265
+ * A ClickHouse `Date` column ("YYYY-MM-DD") as a CalendarDate. Null/empty and the `1970-01-01`
266
+ * zero-date sentinel (CH's null-Date placeholder) map to `null`.
267
+ */
268
+ const calendarFromChDate = (s: null | string | undefined): CalendarDate | null =>
269
+ s && s !== '1970-01-01' ? parseDate(s) : null;
270
+
271
+ /**
272
+ * Compact, human relative time for a **past** instant, rendered in the viewer's timezone:
273
+ * "Just now" (< 1 min), "5m" (< 1 h), "3h" (< 1 day), "Yesterday" (exactly 1 day), then "4d".
274
+ * Elapsed time is the absolute now→`zdt` gap, so the result is timezone-independent.
275
+ */
276
+ const formatRelativeShort = (zdt: ZonedDateTime): string => {
277
+ const minutes = Math.floor(now(getLocalTimeZone()).compare(zdt) / 60_000);
278
+ if (minutes < 1) return 'Just now';
279
+ if (minutes < 60) return `${minutes}m`;
280
+ const hours = Math.floor(minutes / 60);
281
+ if (hours < 24) return `${hours}h`;
282
+ const days = Math.floor(hours / 24);
283
+ if (days === 1) return 'Yesterday';
284
+ return `${days}d`;
285
+ };
286
+
287
+ /**
288
+ * Full-word relative time for a **past** instant: "Just now" (< 1 min), "5 minutes ago",
289
+ * "1 hour ago", "10 hours ago", "3 days ago"; 30 days or more falls back to the full local date.
290
+ * Elapsed time is the absolute gap, so the result is timezone-independent. `from` is the "now"
291
+ * to measure against (injectable for tests).
292
+ */
293
+ const formatRelativeAgo = (
294
+ zdt: ZonedDateTime,
295
+ from: ZonedDateTime = now(getLocalTimeZone())
296
+ ): string => {
297
+ const minutes = Math.floor(from.compare(zdt) / 60_000);
298
+ if (minutes < 1) return 'Just now';
299
+ if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
300
+ const hours = Math.floor(minutes / 60);
301
+ if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
302
+ const days = Math.floor(hours / 24);
303
+ if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'} ago`;
304
+ return formatLocalDateFull(zdt);
305
+ };
306
+
307
+ /** Date/time formatting + wire-format conversion — a plain object of functions (a Library, same
308
+ * pattern as `Crypto`/`Schema`/`Normalize`), not a service: pure functions, no I/O. `ServerDt`
309
+ * (above) is a tiny library of its own already — `now()`/`today()` in the server timezone —
310
+ * so it stays a standalone export rather than folding in here. */
311
+ export const Datetime = {
312
+ calendarFromChDate,
313
+ clampSpanStart,
314
+ daysSince,
315
+ dayStartUnix,
316
+ daysUntil,
317
+ formatAbsolute,
318
+ formatDateFull,
319
+ formatDateISO,
320
+ formatDateShort,
321
+ formatDateWithDaysAgo,
322
+ formatDayShort,
323
+ formatLocalDateFull,
324
+ formatLocalDateNum,
325
+ formatLocalDateTime,
326
+ formatLocalTime,
327
+ formatRelativeAgo,
328
+ formatRelativeShort,
329
+ formatTime,
330
+ formatWeekdayDay,
331
+ getDayOfDate,
332
+ toUnixMillis,
333
+ toUnixSeconds,
334
+ weekdayShort,
335
+ zonedFromPgTimestamp,
336
+ zonedFromUnixMillis,
337
+ zonedFromUnixSeconds,
338
+ };
package/utils/email.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { Format } from './strings/format';
2
+
3
+ /**
4
+ * Transactional email layout, table-based with every style
5
+ * inlined (the only markup that renders consistently across mail clients), a hidden preheader line,
6
+ * an accent strip, a heading, label/value rows, one call-to-action button and a footer. The header
7
+ * is a styled text wordmark, not an image or inline SVG, which Outlook, Gmail and Yahoo strip.
8
+ *
9
+ * Brand-agnostic: callers pass an `EmailBrand`. Colours are hex strings because mail clients have no
10
+ * CSS variables. The same params also render a plain-text alternative, so the two never drift.
11
+ */
12
+
13
+ export type EmailBrand = {
14
+ /** Strip, badge and links. */
15
+ accent: string;
16
+ /** Text on a white card that must stay readable: wordmark, button fill. */
17
+ accentDark: string;
18
+ /** Badge background. */
19
+ accentSoft: string;
20
+ /** Printed in the footer, e.g. a postal address. */
21
+ address?: string;
22
+ name: string;
23
+ supportEmail: string;
24
+ };
25
+
26
+ export type EmailRow = {
27
+ label: string;
28
+ strong?: boolean;
29
+ /** Smaller line under the label, e.g. "2 × £18.00". */
30
+ sub?: string;
31
+ value: string;
32
+ };
33
+
34
+ export type EmailSection =
35
+ { lines: string[]; title: string } | { rows: EmailRow[]; title?: string };
36
+
37
+ export type EmailParams = {
38
+ badge?: string;
39
+ brand: EmailBrand;
40
+ cta?: { href: string; label: string };
41
+ eyebrow: string;
42
+ footnote?: string;
43
+ heading: string;
44
+ /** Paragraphs under the heading. */
45
+ intro: string[];
46
+ /** Inbox-preview line. */
47
+ preheader: string;
48
+ sections: EmailSection[];
49
+ };
50
+
51
+ const FONT =
52
+ "'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
53
+ const INK = '#1f2937';
54
+ const MUTED = '#6b7280';
55
+ const RULE = '#e5e7eb';
56
+
57
+ const e = Format.escapeHtml;
58
+
59
+ function renderRows(rows: EmailRow[]): string {
60
+ return rows
61
+ .map(
62
+ (row, i) => `
63
+ ${i > 0 ? `<tr><td colspan="2" style="border-top:1px solid ${RULE};height:1px;font-size:0;line-height:0;">&nbsp;</td></tr>` : ''}
64
+ <tr>
65
+ <td style="font-family:${FONT};font-size:14px;line-height:1.4;color:${INK};padding:12px 12px 12px 0;font-weight:${row.strong ? 700 : 400};">
66
+ ${e(row.label)}${row.sub ? `<br /><span style="font-size:12.5px;color:${MUTED};font-weight:400;">${e(row.sub)}</span>` : ''}
67
+ </td>
68
+ <td align="right" valign="top" style="font-family:${FONT};font-size:14px;line-height:1.4;color:${INK};padding:12px 0;white-space:nowrap;font-weight:${row.strong ? 700 : 500};">${e(row.value)}</td>
69
+ </tr>`
70
+ )
71
+ .join('');
72
+ }
73
+
74
+ function renderSection(section: EmailSection): string {
75
+ const title = section.title
76
+ ? `<p style="font-family:${FONT};font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:${MUTED};margin:0 0 4px;">${e(section.title)}</p>`
77
+ : '';
78
+ const body =
79
+ 'rows' in section
80
+ ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${renderRows(section.rows)}</table>`
81
+ : `<p style="font-family:${FONT};font-size:14px;line-height:1.6;color:${INK};margin:0;">${section.lines.map(e).join('<br />')}</p>`;
82
+ return `
83
+ <tr><td style="padding:0 36px 24px;">
84
+ ${title}
85
+ ${body}
86
+ </td></tr>`;
87
+ }
88
+
89
+ export function renderEmail(params: EmailParams): string {
90
+ const { brand } = params;
91
+ return `<!DOCTYPE html>
92
+ <html lang="en">
93
+ <head>
94
+ <meta charset="utf-8" />
95
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
96
+ <meta name="color-scheme" content="light" />
97
+ <title>${e(params.heading)}</title>
98
+ </head>
99
+ <body style="background:#f4f5f0;margin:0;padding:0;">
100
+ <div style="display:none;max-height:0;overflow:hidden;">${e(params.preheader)}&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>
101
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f4f5f0;">
102
+ <tr><td align="center" style="padding:40px 16px 48px;">
103
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;">
104
+
105
+ <tr><td style="padding:0 4px 20px;">
106
+ <span style="font-family:${FONT};font-size:22px;font-weight:700;letter-spacing:-0.02em;color:${brand.accentDark};">${e(brand.name)}</span>
107
+ </td></tr>
108
+
109
+ <tr><td style="background:#ffffff;border:1px solid ${RULE};border-radius:12px;">
110
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
111
+ <tr><td style="background:${brand.accent};height:4px;border-radius:12px 12px 0 0;font-size:0;line-height:0;">&nbsp;</td></tr>
112
+
113
+ <tr><td style="padding:30px 36px 8px;">
114
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
115
+ <tr>
116
+ <td><span style="font-family:${FONT};font-size:11px;font-weight:600;color:${MUTED};text-transform:uppercase;letter-spacing:0.08em;">${e(params.eyebrow)}</span></td>
117
+ ${params.badge ? `<td align="right"><span style="display:inline-block;background:${brand.accentSoft};color:${brand.accentDark};font-family:${FONT};font-size:11px;font-weight:600;padding:4px 10px;border-radius:99px;">${e(params.badge)}</span></td>` : ''}
118
+ </tr>
119
+ </table>
120
+ <h1 style="font-family:${FONT};font-size:26px;font-weight:700;color:${INK};letter-spacing:-0.03em;line-height:1.25;margin:16px 0 12px;">${e(params.heading)}</h1>
121
+ ${params.intro.map((p) => `<p style="font-family:${FONT};font-size:14.5px;line-height:1.6;color:#4b5563;margin:0 0 12px;">${e(p)}</p>`).join('\n\t\t\t\t\t\t\t\t')}
122
+ </td></tr>
123
+
124
+ <tr><td style="padding:12px 36px 8px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="border-top:1px solid ${RULE};height:1px;font-size:0;line-height:0;">&nbsp;</td></tr></table></td></tr>
125
+ ${params.sections.map(renderSection).join('')}
126
+
127
+ ${
128
+ params.cta
129
+ ? `<tr><td style="padding:0 36px 32px;">
130
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0">
131
+ <tr><td style="background:${brand.accentDark};border-radius:8px;">
132
+ <a href="${e(params.cta.href)}" style="display:inline-block;font-family:${FONT};font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;padding:12px 24px;">${e(params.cta.label)}</a>
133
+ </td></tr>
134
+ </table>
135
+ </td></tr>`
136
+ : ''
137
+ }
138
+ </table>
139
+ </td></tr>
140
+
141
+ <tr><td align="center" style="padding-top:28px;">
142
+ <p style="font-family:${FONT};font-size:12.5px;color:${MUTED};line-height:1.7;margin:0;text-align:center;">
143
+ ${params.footnote ? `${e(params.footnote)}<br />` : ''}
144
+ Questions? Reply to this email or write to <a href="mailto:${e(brand.supportEmail)}" style="color:${MUTED};text-decoration:underline;">${e(brand.supportEmail)}</a>.${brand.address ? `<br />${e(brand.name)}, ${e(brand.address)}` : ''}
145
+ </p>
146
+ </td></tr>
147
+
148
+ </table>
149
+ </td></tr>
150
+ </table>
151
+ </body>
152
+ </html>`;
153
+ }
154
+
155
+ /** The same content as plain text, for clients that don't show HTML and as the multipart fallback. */
156
+ export function renderEmailText(params: EmailParams): string {
157
+ const out: string[] = [params.heading, '', ...params.intro.flatMap((p) => [p, ''])];
158
+ for (const section of params.sections) {
159
+ if (section.title) out.push(section.title.toUpperCase());
160
+ if ('rows' in section) {
161
+ for (const row of section.rows) {
162
+ out.push(`${row.label}${row.sub ? ` (${row.sub})` : ''}: ${row.value}`);
163
+ }
164
+ } else {
165
+ out.push(...section.lines);
166
+ }
167
+ out.push('');
168
+ }
169
+ if (params.cta) out.push(`${params.cta.label}: ${params.cta.href}`, '');
170
+ if (params.footnote) out.push(params.footnote, '');
171
+ out.push(`Questions? Reply to this email or write to ${params.brand.supportEmail}.`);
172
+ return out.join('\n') + '\n';
173
+ }
package/utils/env.ts ADDED
@@ -0,0 +1,171 @@
1
+ /** Config vars (`vars` in wrangler.jsonc) AND secrets (Secrets Store bindings in prod, plain
2
+ * strings in local `.dev.vars`/`.env`) — one store for both. Construct directly in tests
3
+ * (`new EnvStore({ AUTH_URL: '...' })`) — real validation logic, no fake method stubs to keep in
4
+ * sync. Production gets the one real instance via `resolve()`/`envStore()` below. */
5
+ export class EnvStore {
6
+ private static instance: EnvStore | null = null;
7
+
8
+ private readonly secrets: Record<string, string> = {};
9
+
10
+ constructor(private readonly env: Record<string, unknown>) {}
11
+
12
+ static current(): EnvStore {
13
+ if (!EnvStore.instance) throw new Error('EnvStore.resolve() has not run yet');
14
+ return EnvStore.instance;
15
+ }
16
+
17
+ /** Test-only: clears the cached instance so the next `resolve()` call rebuilds against
18
+ * whatever env is current. A real isolate never needs this — the binding is isolate-stable
19
+ * for its whole lifetime; only a test process resolving many different envs does. */
20
+ static resetForTests(): void {
21
+ EnvStore.instance = null;
22
+ }
23
+
24
+ /** Builds the one real instance for this isolate — idempotent. Called from `runWithContext`,
25
+ * or directly (a Durable Object, say) if that never runs. Takes `unknown` so callers never
26
+ * need their own cast.
27
+ *
28
+ * Eagerly resolves every binding named in `secretNames` so `secret()` can stay sync afterward.
29
+ * Gated on the name, not the binding's shape: Secrets Store, Service Bindings, and VPC services
30
+ * are all backed by the identical `Fetcher` RPC stub (same `constructor.name`, same prototype,
31
+ * zero own keys — verified live), so a prior attempt at excluding fetch-shaped bindings from
32
+ * this scan (#1262) instead excluded every real secret too and broke them all in prod (reverted
33
+ * in #1263). Filtering by name is the only signal that's actually reliable, and as a side
34
+ * effect it also means `.get()` is never called on a Service/VPC binding, so it stops causing a
35
+ * spurious cross-worker RPC exception log too. `secretNames` itself is app config
36
+ * (the names of the Secrets Store bindings), not here — this package stays account-agnostic, the actual
37
+ * list of real secret names is per-app config.
38
+ *
39
+ * Local resolution is unreliable in two distinct ways (see the wiki): `.get()` can throw (an
40
+ * unseeded binding), or it can resolve cleanly to something that isn't a real secret at all —
41
+ * `null`, or even a non-string value. Either case falls back to a same-named `process.env`
42
+ * value (populated from `.env` by the SvelteKit/Vite dev server) so `secret()` works the same
43
+ * locally as in prod without every app re-implementing this fallback itself; if that's unset
44
+ * too, the secret is left unresolved, surfaced only if something actually reads it via
45
+ * `secret()`. `process` is absent in the real Workers runtime (no `nodejs_compat` polyfill
46
+ * covers it here), so this fallback only ever fires under local dev. */
47
+ static async resolve(env: unknown, secretNames: ReadonlySet<string>): Promise<void> {
48
+ if (EnvStore.instance) return;
49
+ const raw = env as Record<string, unknown>;
50
+ const instance = new EnvStore(raw);
51
+ const nodeEnv = typeof process === 'undefined' ? undefined : process.env;
52
+ await Promise.all(
53
+ Object.entries(raw).map(async ([name, value]) => {
54
+ if (
55
+ secretNames.has(name) &&
56
+ value != null &&
57
+ typeof value === 'object' &&
58
+ typeof (value as { get?: unknown }).get === 'function'
59
+ ) {
60
+ let resolved: string | undefined;
61
+ try {
62
+ const got = await (value as { get(): Promise<unknown> }).get();
63
+ resolved = typeof got === 'string' && got.length > 0 ? got : undefined;
64
+ } catch {
65
+ resolved = undefined;
66
+ }
67
+ const fallback = resolved ?? nodeEnv?.[name];
68
+ if (fallback) instance.secrets[name] = fallback;
69
+ }
70
+ })
71
+ );
72
+ EnvStore.instance = instance;
73
+ }
74
+
75
+ /** Any other raw binding (Workers AI, R2, a Durable Object namespace, …) that isn't a plain
76
+ * var/VPC-fetch-shaped/secret — just existence-checked, then handed back as `T`. */
77
+ binding<T>(name: string): T {
78
+ const value = this.env[name];
79
+ if (!value) throw new Error(`EnvStore: "${name}" binding is not configured`);
80
+ return value as T;
81
+ }
82
+
83
+ /** A Cloudflare Hyperdrive binding. */
84
+ hyperdrive(name: string): Hyperdrive {
85
+ return this.binding<Hyperdrive>(name);
86
+ }
87
+
88
+ /** A numeric `vars` entry (e.g. `SENDER_BATCH_LIMIT: "500"`) — still a string in
89
+ * wrangler.jsonc, parsed and validated here instead of a bare `Number(...)` at the call site,
90
+ * which silently produces `NaN` on a bad value rather than failing. */
91
+ int(name: string): number {
92
+ const value = this.string(name);
93
+ const parsed = Number(value);
94
+ if (!Number.isInteger(parsed))
95
+ throw new Error(`EnvStore: "${name}" ("${value}") is not an integer`);
96
+ return parsed;
97
+ }
98
+
99
+ /** A secret — a Secrets Store binding in prod (resolved once in `resolve()` for names in
100
+ * `SECRET_STORE_BINDING_NAMES`), or a plain string in local `.dev.vars`/`.env`. A name missing
101
+ * from that set just fails loudly here at first call instead of at compile time, same tradeoff
102
+ * `string()` already makes. */
103
+ secret(name: string): string {
104
+ const resolved = this.secrets[name];
105
+ if (resolved != null) return resolved;
106
+ const value = this.env[name];
107
+ if (typeof value === 'string') return value;
108
+ throw new Error(`EnvStore: "${name}" secret is not configured in this env`);
109
+ }
110
+
111
+ /** Same as `secret()` but for a secret that's genuinely optional — `null` instead of throwing
112
+ * when unresolved (unset in this env, or an unseeded local Secrets Store binding with no
113
+ * `.env` fallback either). */
114
+ secretNull(name: string): null | string {
115
+ const resolved = this.secrets[name];
116
+ if (resolved != null) return resolved;
117
+ const value = this.env[name];
118
+ return typeof value === 'string' && value !== '' ? value : null;
119
+ }
120
+
121
+ /** No enumerated key list: this env's own vars vary too widely per app/worker to maintain one,
122
+ * and a typo'd name still fails loudly, just at first call instead of at compile time. */
123
+ string(name: string): string {
124
+ const value = this.lookup(name);
125
+ if (typeof value !== 'string') throw new Error(`EnvStore: "${name}" is not a string`);
126
+ return value;
127
+ }
128
+
129
+ /** Same as `string()` but for a var that's genuinely optional (some apps/workers set it,
130
+ * others don't) — `null` instead of throwing when unset. */
131
+ stringNull(name: string): null | string {
132
+ const value = this.env[name];
133
+ if (value == null || value === '') return null;
134
+ if (typeof value !== 'string') throw new Error(`EnvStore: "${name}" is not a string`);
135
+ return value;
136
+ }
137
+
138
+ /** A Workers VPC Service binding (`fetch`/`connect`, real Cloudflare shape — no cast needed at
139
+ * the call site). */
140
+ vpc(name: string): Fetcher {
141
+ const value = this.env[name];
142
+ if (!value) throw new Error(`EnvStore: "${name}" VPC binding is not configured`);
143
+ return value as Fetcher;
144
+ }
145
+
146
+ /** Same as `vpc()` but for a VPC binding that's genuinely optional (e.g. local dev has no VPC
147
+ * services at all) — `null` instead of throwing when unset. */
148
+ vpcNull(name: string): Fetcher | null {
149
+ const value = this.env[name];
150
+ return value ? (value as Fetcher) : null;
151
+ }
152
+
153
+ private lookup(name: string): unknown {
154
+ const value = this.env[name];
155
+ if (
156
+ value != null &&
157
+ typeof value === 'object' &&
158
+ typeof (value as { get?: unknown }).get === 'function'
159
+ ) {
160
+ throw new Error(`EnvStore: "${name}" is a Secrets Store binding — use secret() instead`);
161
+ }
162
+ if (value == null || value === '') {
163
+ throw new Error(`EnvStore: "${name}" is not configured in this env`);
164
+ }
165
+ return value;
166
+ }
167
+ }
168
+
169
+ export function envStore(): EnvStore {
170
+ return EnvStore.current();
171
+ }