@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.
- package/README.md +67 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +25 -51
- package/cloudflare/email/dev.ts +9 -5
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +8 -3
- package/package.json +9 -3
- package/server/remote-functions.ts +1 -1
- package/utils/schema.ts +0 -35
|
@@ -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 {
|
|
3
|
+
import { BaseEmailClient, type EmailInput, type EmailSender } from './base';
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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.
|
|
32
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/cloudflare/email/dev.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type { EmailInput, IEmailClient } from './
|
|
1
|
+
import type { EmailInput, IEmailClient } from './base';
|
|
2
2
|
|
|
3
|
-
/** Stands in for
|
|
4
|
-
*
|
|
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
|
|
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 './
|
|
1
|
+
import type { EmailInput, IEmailClient } from './base';
|
|
2
2
|
|
|
3
|
-
/** Records every message
|
|
4
|
-
*
|
|
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.
|
|
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 (
|
|
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);
|