@zeyolabs/kit 2026.11.5 → 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.
@@ -0,0 +1,264 @@
1
+ import type { RequestEvent, RequestHandler } from '@sveltejs/kit';
2
+
3
+ import { json, redirect } from '@sveltejs/kit';
4
+ import { eq } from 'drizzle-orm';
5
+ import { z } from 'zod';
6
+
7
+ import type { AuthUser } from '../sessions/sessions';
8
+ import type { LoginError } from './login';
9
+
10
+ import { logger } from '../../server/logger';
11
+ import { ServiceError } from '../../utils/errors';
12
+ import { Schema } from '../../utils/schema';
13
+ import { Invites } from '../accounts/invites';
14
+ import { Auth } from '../config';
15
+ import { AuthEmails } from '../emails';
16
+ import { users } from '../schema';
17
+ import { AuthHandle } from '../sessions/handle';
18
+ import { MagicLink } from '../sessions/magic-link/magic-link';
19
+ import {
20
+ AuthenticationResponse,
21
+ PasskeyError,
22
+ RegistrationResponse,
23
+ type RelyingParty,
24
+ } from '../sessions/passkeys/passkeys';
25
+ import { Passkeys } from '../sessions/passkeys/passkeys';
26
+ import { Sessions } from '../sessions/sessions';
27
+ import { Users } from '../users';
28
+ import { Login } from './login';
29
+
30
+ // The auth endpoints, mounted by the app with one file:
31
+ // src/routes/auth/[...path]/+server.ts: export { POST } from '@zeyolabs/kit/auth/sveltekit/routes';
32
+ //
33
+ // POST /auth/email form {email, next} -> emails a link, 303 login?sent=1
34
+ // POST /auth/verify form {token, next} -> uses the link, signs in, 303 next
35
+ // POST /auth/logout form -> 303 login
36
+ // POST /auth/passkey/login-options json -> request options
37
+ // POST /auth/passkey/login json {credential, next} -> signs in, {redirect}
38
+ // POST /auth/passkey/register-options json -> creation options (fresh session)
39
+ // POST /auth/passkey/register json {credential, name} -> {ok}
40
+ // POST /auth/passkey/delete form {id, next} -> 303 next
41
+ // POST /auth/invite/accept form {token} -> joins the account, 303 its home
42
+ //
43
+ // The emailed link opens the login page (`?token=`), which posts it here from a button: mail
44
+ // scanners that prefetch links can't burn the token or sign anyone in.
45
+
46
+ const CHALLENGE_MAX_AGE = 5 * 60;
47
+
48
+ const challengeCookie = () => `${Auth.config().cookieName}_challenge`;
49
+
50
+ function relyingParty(event: RequestEvent): RelyingParty {
51
+ const { appName, passkeyRpId } = Auth.config();
52
+ return { id: passkeyRpId ?? event.url.hostname, name: appName, origin: event.url.origin };
53
+ }
54
+
55
+ function toLogin(error: LoginError | null, next: string, sent = false): never {
56
+ const params = new URLSearchParams();
57
+ if (error) params.set('error', error);
58
+ if (sent) params.set('sent', '1');
59
+ if (next !== '/') params.set('next', next);
60
+ const query = params.toString();
61
+ redirect(303, `${Auth.config().loginPath}${query ? `?${query}` : ''}`);
62
+ }
63
+
64
+ /** Checks a would-be session user against the allowlist and the Access identity. */
65
+ async function refusal(event: RequestEvent, email: string): Promise<LoginError | null> {
66
+ if (!(await Auth.config().isAllowed(email))) return 'not_allowed';
67
+ const { accessEmail } = AuthHandle.locals(event);
68
+ if (accessEmail && !Users.emailsMatch(accessEmail, email)) return 'mismatch';
69
+ return null;
70
+ }
71
+
72
+ async function signIn(event: RequestEvent, user: AuthUser): Promise<void> {
73
+ const { expiresAt, token } = await Sessions.create(Auth.config().db(), user.id);
74
+ event.cookies.set(Auth.config().cookieName, token, {
75
+ ...Sessions.cookieAttributes,
76
+ expires: new Date(expiresAt),
77
+ });
78
+ await Auth.config().onSignIn?.(user);
79
+ }
80
+
81
+ function setChallenge(event: RequestEvent): string {
82
+ const challenge = Passkeys.newChallenge();
83
+ event.cookies.set(challengeCookie(), challenge, {
84
+ ...Sessions.cookieAttributes,
85
+ maxAge: CHALLENGE_MAX_AGE,
86
+ path: '/auth/passkey',
87
+ sameSite: 'strict',
88
+ });
89
+ return challenge;
90
+ }
91
+
92
+ /** The pending challenge, used up: each one answers exactly one ceremony. */
93
+ function takeChallenge(event: RequestEvent): string {
94
+ const challenge = event.cookies.get(challengeCookie());
95
+ event.cookies.delete(challengeCookie(), { path: '/auth/passkey' });
96
+ if (!challenge) throw new PasskeyError('No pending challenge');
97
+ return challenge;
98
+ }
99
+
100
+ /** Adding or removing a passkey needs a session, and adding one needs a recent sign-in. */
101
+ function sessionUser(event: RequestEvent, fresh: boolean): AuthUser | Response {
102
+ const { session, user } = AuthHandle.locals(event);
103
+ if (!session || !user) return json({ message: 'Sign in first' }, { status: 401 });
104
+ if (fresh && Date.now() - session.createdAt > Passkeys.FRESH_SESSION_MS) {
105
+ return json(
106
+ { message: 'For security, sign in again before adding a passkey', reauth: true },
107
+ { status: 403 }
108
+ );
109
+ }
110
+ return user;
111
+ }
112
+
113
+ async function emailLink(event: RequestEvent): Promise<never> {
114
+ const form = await event.request.formData();
115
+ const next = Login.safeNext(form.get('next'));
116
+ const parsed = Schema.Email.safeParse(form.get('email'));
117
+ if (!parsed.success) toLogin('send', next);
118
+ const email = parsed.data;
119
+
120
+ const refused = await refusal(event, email);
121
+ // Unknown emails get the same answer as known ones, so the form can't probe the allowlist.
122
+ if (refused === 'not_allowed') toLogin(null, next, true);
123
+ if (refused) toLogin(refused, next);
124
+
125
+ const token = await MagicLink.issue(Auth.config().db(), email);
126
+ if (token) {
127
+ const url = new URL(Auth.config().loginPath, event.url.origin);
128
+ url.searchParams.set('token', token);
129
+ if (next !== '/') url.searchParams.set('next', next);
130
+ try {
131
+ const message = { email, expiresInMinutes: MagicLink.TTL_MS / 60_000, url: url.href };
132
+ await Auth.config()
133
+ .email()
134
+ .deliver({ to: email, ...AuthEmails.magicLink(Auth.config().emailBrand, message) });
135
+ } catch (err) {
136
+ logger.error(err, { op: 'auth.magic-link.send' });
137
+ toLogin('send', next);
138
+ }
139
+ }
140
+ toLogin(null, next, true);
141
+ }
142
+
143
+ async function verifyLink(event: RequestEvent): Promise<never> {
144
+ const form = await event.request.formData();
145
+ const next = Login.safeNext(form.get('next'));
146
+ const email = await MagicLink.consume(Auth.config().db(), String(form.get('token') ?? ''));
147
+ if (!email) toLogin('expired', next);
148
+ const refused = await refusal(event, email);
149
+ if (refused) toLogin(refused, next);
150
+ await signIn(event, await Users.findOrCreate(Auth.config().db(), email));
151
+ redirect(303, next);
152
+ }
153
+
154
+ async function logout(event: RequestEvent): Promise<never> {
155
+ const { session } = AuthHandle.locals(event);
156
+ if (session) await Sessions.delete(Auth.config().db(), session.id);
157
+ event.cookies.delete(Auth.config().cookieName, { path: '/' });
158
+ redirect(303, Auth.config().loginPath);
159
+ }
160
+
161
+ async function passkeyLogin(event: RequestEvent): Promise<Response> {
162
+ const body = z
163
+ .object({ credential: AuthenticationResponse, next: z.string().optional() })
164
+ .safeParse(await event.request.json().catch(() => null));
165
+ if (!body.success) return json({ message: 'Malformed request' }, { status: 400 });
166
+ const db = Auth.config().db();
167
+ try {
168
+ const challenge = takeChallenge(event);
169
+ const passkey = await Passkeys.find(db, body.data.credential.rawId);
170
+ if (!passkey) throw new PasskeyError('Unknown passkey');
171
+ const signCount = Passkeys.verifyAuthentication(
172
+ body.data.credential,
173
+ challenge,
174
+ relyingParty(event),
175
+ passkey
176
+ );
177
+ const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1);
178
+ const refused = await refusal(event, user.email);
179
+ if (refused) return json({ code: refused, message: 'Not allowed' }, { status: 403 });
180
+ await Passkeys.recordUse(db, passkey.id, signCount);
181
+ await signIn(event, user);
182
+ return json({ redirect: Login.safeNext(body.data.next) });
183
+ } catch (err) {
184
+ if (!(err instanceof PasskeyError)) throw err;
185
+ logger.warn(`passkey sign-in refused: ${err.message}`);
186
+ return json({ message: 'That passkey could not be verified' }, { status: 400 });
187
+ }
188
+ }
189
+
190
+ async function passkeyRegisterOptions(event: RequestEvent): Promise<Response> {
191
+ const user = sessionUser(event, true);
192
+ if (user instanceof Response) return user;
193
+ const existing = await Passkeys.list(Auth.config().db(), user.id);
194
+ const ids = existing.map((p) => p.id);
195
+ return json(Passkeys.registrationOptions(relyingParty(event), user, setChallenge(event), ids));
196
+ }
197
+
198
+ async function passkeyRegister(event: RequestEvent): Promise<Response> {
199
+ const user = sessionUser(event, true);
200
+ if (user instanceof Response) return user;
201
+ const body = z
202
+ .object({ credential: RegistrationResponse, name: z.string().trim().max(60).optional() })
203
+ .safeParse(await event.request.json().catch(() => null));
204
+ if (!body.success) return json({ message: 'Malformed request' }, { status: 400 });
205
+ try {
206
+ const credential = Passkeys.verifyRegistration(
207
+ body.data.credential,
208
+ takeChallenge(event),
209
+ relyingParty(event)
210
+ );
211
+ await Passkeys.save(Auth.config().db(), user.id, body.data.name || 'Passkey', credential);
212
+ return json({ ok: true });
213
+ } catch (err) {
214
+ if (!(err instanceof PasskeyError)) throw err;
215
+ logger.warn(`passkey registration refused: ${err.message}`);
216
+ return json({ message: 'That passkey could not be added' }, { status: 400 });
217
+ }
218
+ }
219
+
220
+ async function passkeyDelete(event: RequestEvent): Promise<never> {
221
+ const form = await event.request.formData();
222
+ const next = Login.safeNext(form.get('next'));
223
+ const user = sessionUser(event, false);
224
+ if (user instanceof Response) toLogin(null, next);
225
+ await Passkeys.delete(Auth.config().db(), user.id, String(form.get('id') ?? ''));
226
+ redirect(303, next);
227
+ }
228
+
229
+ /** Accepts an invite for the signed-in user (the invite page posts here) and opens the account. */
230
+ async function invite(event: RequestEvent): Promise<never> {
231
+ const form = await event.request.formData();
232
+ const token = String(form.get('token') ?? '');
233
+ const { user } = AuthHandle.locals(event);
234
+ const invitePage = `${Auth.config().invitePath}?token=${encodeURIComponent(token)}`;
235
+ if (!user) toLogin(null, invitePage);
236
+ const account = await Invites.accept(Auth.config().db(), token, user).catch((err: unknown) => {
237
+ if (err instanceof ServiceError) redirect(303, `${invitePage}&error=${err.status}`);
238
+ throw err;
239
+ });
240
+ redirect(303, Auth.config().accountHome(account.slug));
241
+ }
242
+
243
+ const handlers: Record<string, (event: RequestEvent) => Promise<Response>> = {
244
+ email: emailLink,
245
+ 'invite/accept': invite,
246
+ logout,
247
+ 'passkey/delete': passkeyDelete,
248
+ 'passkey/login': passkeyLogin,
249
+ 'passkey/login-options': async (event) =>
250
+ json(Passkeys.authenticationOptions(relyingParty(event), setChallenge(event))),
251
+ 'passkey/register': passkeyRegister,
252
+ 'passkey/register-options': passkeyRegisterOptions,
253
+ verify: verifyLink,
254
+ };
255
+
256
+ export const POST: RequestHandler = async (event) => {
257
+ // SvelteKit's CSRF check only covers form posts; the JSON endpoints need the same guard.
258
+ if (event.request.headers.get('origin') !== event.url.origin) {
259
+ return new Response('Cross-site request refused', { status: 403 });
260
+ }
261
+ const handler = handlers[event.params.path ?? ''];
262
+ if (!handler) return new Response('Not found', { status: 404 });
263
+ return handler(event);
264
+ };
package/auth/users.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { eq } from 'drizzle-orm';
2
+
3
+ import type { AuthDb } from './config';
4
+ import type { AuthUser } from './sessions/sessions';
5
+
6
+ import { users } from './schema';
7
+
8
+ /** Parses a comma-separated allowlist (e.g. an `ALLOWED_EMAILS` var) into lower-cased emails. */
9
+ function parseAllowlist(raw: string | undefined): string[] {
10
+ return (raw ?? '')
11
+ .split(',')
12
+ .map((email) => email.trim().toLowerCase())
13
+ .filter(Boolean);
14
+ }
15
+
16
+ /** Case-insensitive allowlist check. An empty list allows nobody (fail closed). */
17
+ function isEmailAllowed(email: string, allowlist: readonly string[] | string | undefined) {
18
+ const list =
19
+ typeof allowlist === 'string' || allowlist === undefined
20
+ ? parseAllowlist(allowlist)
21
+ : allowlist.map((e) => e.trim().toLowerCase());
22
+ return list.includes(email.trim().toLowerCase());
23
+ }
24
+
25
+ function emailsMatch(a: string, b: string): boolean {
26
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
27
+ }
28
+
29
+ /** The user for `email`, created on first sign-in with the email's local part as their name. */
30
+ async function findOrCreateUser(db: AuthDb, email: string): Promise<AuthUser> {
31
+ const normalized = email.trim().toLowerCase();
32
+ await db
33
+ .insert(users)
34
+ .values({ email: normalized, name: normalized.split('@')[0] })
35
+ .onConflictDoNothing({ target: users.email });
36
+ const [user] = await db.select().from(users).where(eq(users.email, normalized)).limit(1);
37
+ const { avatarUrl, id, name } = user;
38
+ return { avatarUrl, email: normalized, id, name };
39
+ }
40
+
41
+ export const Allowlist = {
42
+ includes: isEmailAllowed,
43
+ parse: parseAllowlist,
44
+ };
45
+
46
+ export const Users = {
47
+ emailsMatch,
48
+ findOrCreate: findOrCreateUser,
49
+ };
@@ -0,0 +1,59 @@
1
+ import { logger as defaultLogger, type LoggerClient } from '../../monitoring/logger/client';
2
+
3
+ // What every email client shares: the message shape, the `IEmailClient` contract, and
4
+ // `BaseEmailClient`, which owns the sender and the log-and-return-false `send`. A transport
5
+ // (Workers `send_email` binding, SMTP, ...) only implements `deliver`.
6
+
7
+ export type EmailAttachment = { base64Data: string; contentType: string; filename: string };
8
+
9
+ export type EmailInput = {
10
+ attachments?: EmailAttachment[];
11
+ /** Display name for this message, overriding the client's `fromName`. */
12
+ fromName?: string;
13
+ html?: string;
14
+ replyTo?: { email: string; name?: string };
15
+ subject: string;
16
+ /** Plain-text body (or alternative to `html`). Give at least one of the two. */
17
+ text?: string;
18
+ to: string;
19
+ };
20
+
21
+ export interface IEmailClient {
22
+ /** Sends one message; throws on failure, for callers that retry (queue consumers). */
23
+ deliver(input: EmailInput): Promise<void>;
24
+ /** Sends one message. Logs and returns `false` on failure instead of throwing. */
25
+ send(input: EmailInput): Promise<boolean>;
26
+ }
27
+
28
+ export type EmailSender = {
29
+ /** Envelope and header sender. */
30
+ from: string;
31
+ /** Display name next to `from`. */
32
+ fromName?: string;
33
+ logger?: LoggerClient;
34
+ };
35
+
36
+ export abstract class BaseEmailClient implements IEmailClient {
37
+ constructor(protected readonly sender: EmailSender) {}
38
+
39
+ abstract deliver(input: EmailInput): Promise<void>;
40
+
41
+ async send(input: EmailInput): Promise<boolean> {
42
+ try {
43
+ await this.deliver(input);
44
+ return true;
45
+ } catch (err) {
46
+ await (this.sender.logger ?? defaultLogger).error(err, {
47
+ from: this.sender.from,
48
+ subject: input.subject,
49
+ to: input.to,
50
+ });
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** The display name for one message: its own, else the client's. */
56
+ protected nameFor(input: EmailInput): string | undefined {
57
+ return input.fromName ?? this.sender.fromName;
58
+ }
59
+ }
@@ -1,35 +1,27 @@
1
1
  import { createMimeMessage } from 'mimetext/browser';
2
2
 
3
- import { logger as defaultLogger, type LoggerClient } from '../../monitoring/logger/client';
3
+ import { BaseEmailClient, type EmailInput, type EmailSender } from './base';
4
4
 
5
- export type EmailAttachment = { base64Data: string; contentType: string; filename: string };
6
-
7
- export type EmailInput = {
8
- attachments?: EmailAttachment[];
9
- html: string;
10
- subject: string;
11
- to: string;
12
- };
5
+ export {
6
+ BaseEmailClient,
7
+ type EmailAttachment,
8
+ type EmailInput,
9
+ type EmailSender,
10
+ type IEmailClient,
11
+ } from './base';
13
12
 
14
13
  /** The shape of a Workers `send_email` binding. */
15
14
  export type EmailBinding = { send(message: unknown): Promise<unknown> };
16
15
 
17
- export interface IEmailClient {
18
- /** Sends one message. Logs and returns `false` on failure instead of throwing. */
19
- send(input: EmailInput): Promise<boolean>;
20
- }
21
-
22
- function buildRawEmail(
23
- from: string,
24
- fromName: string | undefined,
25
- { attachments, html, subject, to }: EmailInput
26
- ): string {
16
+ function buildRawEmail(from: string, fromName: string | undefined, input: EmailInput): string {
27
17
  const msg = createMimeMessage();
28
18
  msg.setSender(fromName ? { addr: from, name: fromName } : { addr: from });
29
- msg.setRecipient(to);
30
- msg.setSubject(subject);
31
- msg.addMessage({ contentType: 'text/html', data: html });
32
- for (const attachment of attachments ?? []) {
19
+ msg.setRecipient(input.to);
20
+ msg.setSubject(input.subject);
21
+ if (input.replyTo) msg.setHeader('Reply-To', input.replyTo.email);
22
+ if (input.text) msg.addMessage({ contentType: 'text/plain', data: input.text });
23
+ if (input.html) msg.addMessage({ contentType: 'text/html', data: input.html });
24
+ for (const attachment of input.attachments ?? []) {
33
25
  msg.addAttachment({
34
26
  contentType: attachment.contentType,
35
27
  data: attachment.base64Data,
@@ -41,37 +33,19 @@ function buildRawEmail(
41
33
 
42
34
  /** Sends transactional email over a Workers `send_email` binding (Cloudflare Email Routing).
43
35
  * Use `DevEmailClient` where there's no binding. */
44
- export class EmailClient implements IEmailClient {
45
- constructor(
46
- private readonly config: {
47
- binding: EmailBinding;
48
- /** Envelope and header sender. */
49
- from: string;
50
- /** Display name next to `from`. */
51
- fromName?: string;
52
- logger?: LoggerClient;
53
- }
54
- ) {}
36
+ export class EmailClient extends BaseEmailClient {
37
+ private readonly binding: EmailBinding;
38
+
39
+ constructor(config: EmailSender & { binding: EmailBinding }) {
40
+ super(config);
41
+ this.binding = config.binding;
42
+ }
55
43
 
56
- /** Like `send`, but throws on failure — for callers that retry (queue consumers). */
57
44
  async deliver(input: EmailInput): Promise<void> {
58
- const { binding, from, fromName } = this.config;
45
+ const { from } = this.sender;
59
46
  // Dynamic: `cloudflare:email` only resolves inside the Workers runtime.
60
47
  const { EmailMessage } = await import('cloudflare:email');
61
- await binding.send(new EmailMessage(from, input.to, buildRawEmail(from, fromName, input)));
62
- }
63
-
64
- async send(input: EmailInput): Promise<boolean> {
65
- try {
66
- await this.deliver(input);
67
- return true;
68
- } catch (err) {
69
- await (this.config.logger ?? defaultLogger).error(err, {
70
- from: this.config.from,
71
- subject: input.subject,
72
- to: input.to,
73
- });
74
- return false;
75
- }
48
+ const raw = buildRawEmail(from, this.nameFor(input), input);
49
+ await this.binding.send(new EmailMessage(from, input.to, raw));
76
50
  }
77
51
  }
@@ -1,10 +1,14 @@
1
- import type { EmailInput, IEmailClient } from './client';
1
+ import type { EmailInput, IEmailClient } from './base';
2
2
 
3
- /** Stands in for `EmailClient` where there's no `send_email` binding (local dev) — logs instead of
4
- * sending, so the message is still visible. */
3
+ /** Stands in for a real client where there's no transport (local dev): logs instead of sending,
4
+ * so the message is still visible. */
5
5
  export class DevEmailClient implements IEmailClient {
6
- async send({ subject, to }: EmailInput): Promise<boolean> {
7
- console.log(`[email:dev] to=${to} subject=${subject}`);
6
+ async deliver({ subject, text, to }: EmailInput): Promise<void> {
7
+ console.log(`[email:dev] to=${to} subject=${subject}${text ? `\n${text}` : ''}`);
8
+ }
9
+
10
+ async send(input: EmailInput): Promise<boolean> {
11
+ await this.deliver(input);
8
12
  return true;
9
13
  }
10
14
  }
@@ -0,0 +1,65 @@
1
+ import { envStore } from '../../utils/env';
2
+ import { BaseEmailClient, type EmailInput, type EmailSender } from './base';
3
+
4
+ // Email over SMTP (e.g. Amazon SES) with `worker-mailer`, which speaks SMTP over
5
+ // `cloudflare:sockets`: for sending to any recipient, where a `send_email` binding
6
+ // (`EmailClient`) can't. Same `IEmailClient` contract, so `DevEmailClient` and `FakeEmailClient`
7
+ // stand in for it too.
8
+
9
+ export type SmtpSettings = { host: string; password: string; port: number; username: string };
10
+
11
+ /** The wrangler names of the SMTP settings: three vars and one Secrets Store secret. */
12
+ export type SmtpKeys = { host: string; password: string; port: string; username: string };
13
+
14
+ /** Connection options for worker-mailer: port 465 is implicit TLS, anything else STARTTLS. */
15
+ export function smtpConnection({ host, password, port, username }: SmtpSettings) {
16
+ const implicitTls = port === 465;
17
+ return {
18
+ authType: 'plain' as const,
19
+ credentials: { password, username },
20
+ host,
21
+ port,
22
+ secure: implicitTls,
23
+ startTls: !implicitTls,
24
+ };
25
+ }
26
+
27
+ export class SmtpClient extends BaseEmailClient {
28
+ private readonly settings: SmtpSettings;
29
+
30
+ constructor(config: EmailSender & SmtpSettings) {
31
+ super(config);
32
+ this.settings = config;
33
+ }
34
+
35
+ /** A client whose settings come from `envStore()` under the names in `keys` (so the password's
36
+ * name must be among the request context's `secretNames`). */
37
+ static fromEnv(keys: SmtpKeys, sender: EmailSender): SmtpClient {
38
+ const store = envStore();
39
+ return new SmtpClient({
40
+ ...sender,
41
+ host: store.string(keys.host),
42
+ password: store.secret(keys.password),
43
+ port: store.int(keys.port),
44
+ username: store.string(keys.username),
45
+ });
46
+ }
47
+
48
+ async deliver(input: EmailInput): Promise<void> {
49
+ // Loaded lazily: it imports cloudflare:sockets, which only exists on Workers.
50
+ const { WorkerMailer } = await import('worker-mailer');
51
+ await WorkerMailer.send(smtpConnection(this.settings), {
52
+ attachments: input.attachments?.map((a) => ({
53
+ content: a.base64Data,
54
+ filename: a.filename,
55
+ mimeType: a.contentType,
56
+ })),
57
+ from: { email: this.sender.from, name: this.nameFor(input) },
58
+ html: input.html,
59
+ reply: input.replyTo,
60
+ subject: input.subject,
61
+ text: input.text,
62
+ to: input.to,
63
+ });
64
+ }
65
+ }
@@ -1,11 +1,16 @@
1
- import type { EmailInput, IEmailClient } from './client';
1
+ import type { EmailInput, IEmailClient } from './base';
2
2
 
3
- /** Records every message passed to `send` instead of sending anything — for asserting what a
4
- * service emailed, without a mock binding. Set `succeeds = false` to simulate a delivery failure. */
3
+ /** Records every message instead of sending anything — for asserting what a service emailed,
4
+ * without a mock transport. Set `succeeds = false` to simulate a delivery failure. */
5
5
  export class FakeEmailClient implements IEmailClient {
6
6
  readonly sent: EmailInput[] = [];
7
7
  succeeds = true;
8
8
 
9
+ async deliver(input: EmailInput): Promise<void> {
10
+ this.sent.push(input);
11
+ if (!this.succeeds) throw new Error('delivery failed');
12
+ }
13
+
9
14
  async send(input: EmailInput): Promise<boolean> {
10
15
  this.sent.push(input);
11
16
  return this.succeeds;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zeyolabs/kit",
3
- "version": "2026.11.5",
4
- "description": "Shared SvelteKit-on-Cloudflare server toolkit: remote-function wrappers, Cloudflare context, monitoring, storage clients, utils and the ESLint config.",
3
+ "version": "2026.11.6",
4
+ "description": "Shared SvelteKit-on-Cloudflare server toolkit: remote-function wrappers, passwordless auth (magic link + passkeys), email over a send_email binding or SMTP, Cloudflare context, monitoring, storage clients, utils and the ESLint config.",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "eslint-config.js",
21
+ "auth",
21
22
  "cloudflare",
22
23
  "monitoring",
23
24
  "server",
@@ -43,6 +44,7 @@
43
44
  "exports": {
44
45
  "./eslint-config": "./eslint-config.js",
45
46
  "./utils/*": "./utils/*.ts",
47
+ "./auth/*": "./auth/*.ts",
46
48
  "./server/*": "./server/*.ts",
47
49
  "./cloudflare/*": "./cloudflare/*.ts",
48
50
  "./cloudflare/context": "./cloudflare/context.ts",
@@ -50,6 +52,7 @@
50
52
  "./cloudflare/dispatch": "./cloudflare/dispatch/client.ts",
51
53
  "./cloudflare/email": "./cloudflare/email/client.ts",
52
54
  "./cloudflare/email/dev": "./cloudflare/email/dev.ts",
55
+ "./cloudflare/email/smtp": "./cloudflare/email/smtp.ts",
53
56
  "./cloudflare/email/testing": "./cloudflare/email/testing.ts",
54
57
  "./cloudflare/r2": "./cloudflare/r2/client.ts",
55
58
  "./cloudflare/r2/testing": "./cloudflare/r2/testing.ts",
@@ -92,6 +95,7 @@
92
95
  "@internationalized/date": "3.12.4",
93
96
  "@oslojs/crypto": "1.0.1",
94
97
  "@oslojs/encoding": "1.1.0",
98
+ "@oslojs/webauthn": "1.0.0",
95
99
  "@sentry/cloudflare": "11.4.0",
96
100
  "aws4fetch": "1.0.20",
97
101
  "eslint-config-prettier": "10.1.8",
@@ -105,7 +109,8 @@
105
109
  "snappyjs": "0.7.0",
106
110
  "svelte-eslint-parser": "1.8.1",
107
111
  "tldts": "7.4.16",
108
- "typescript-eslint": "8.71.0"
112
+ "typescript-eslint": "8.71.0",
113
+ "worker-mailer": "1.3.1"
109
114
  },
110
115
  "peerDependencies": {
111
116
  "@cloudflare/workers-types": "*",
@@ -136,6 +141,7 @@
136
141
  "@bufbuild/protoc-gen-es": "2.16.0",
137
142
  "@cloudflare/workers-types": "5.20261005.1",
138
143
  "@electric-sql/pglite": "0.5.8",
144
+ "@libsql/client": "0.18.0",
139
145
  "@sentry/sveltekit": "11.4.0",
140
146
  "@sveltejs/kit": "3.1.1",
141
147
  "@types/pg": "8.23.1",
@@ -97,7 +97,7 @@ function remoteForm<S extends z.ZodType, O>(
97
97
  try {
98
98
  return await run(data);
99
99
  } catch (e) {
100
- // A ServiceError (wrong password, email in use, ...), a soft Fail, or a directly-thrown
100
+ // A ServiceError (expired link, email in use, ...), a soft Fail, or a directly-thrown
101
101
  // HttpError becomes an inline form error; anything else is unexpected but still becomes an
102
102
  // inline error, because throwing here would break `<Form>`.
103
103
  if (e instanceof ServiceError) return FormResult.err(e.message);