@donkeylabs/cli 1.1.15 → 1.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -33,8 +33,10 @@ NODE_ENV=development
33
33
  # EXTERNAL SERVICES (examples)
34
34
  # =============================================================================
35
35
 
36
- # Email service (e.g., Resend, SendGrid)
36
+ # Email service (Resend)
37
37
  # RESEND_API_KEY=re_xxxxxxxxxxxx
38
+ # EMAIL_FROM=noreply@yourdomain.com
39
+ # PUBLIC_BASE_URL=https://yourdomain.com
38
40
 
39
41
  # Payment processing (e.g., Stripe)
40
42
  # STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
@@ -6,6 +6,7 @@ import { Database } from "bun:sqlite";
6
6
  import { demoPlugin } from "./plugins/demo";
7
7
  import { workflowDemoPlugin } from "./plugins/workflow-demo";
8
8
  import { authPlugin } from "./plugins/auth";
9
+ import { emailPlugin } from "./plugins/email";
9
10
  import demoRoutes from "./routes/demo";
10
11
  import { exampleRouter } from "./routes/example";
11
12
  import { authRouter } from "./routes/auth";
@@ -56,6 +57,16 @@ export const server = new AppServer({
56
57
 
57
58
  // Using default session strategy for this template
58
59
  server.registerPlugin(authPlugin());
60
+
61
+ // Email plugin - supports Resend or console (for development)
62
+ // Configure with process.env.RESEND_API_KEY for production
63
+ server.registerPlugin(emailPlugin({
64
+ provider: process.env.RESEND_API_KEY ? "resend" : "console",
65
+ resend: process.env.RESEND_API_KEY ? { apiKey: process.env.RESEND_API_KEY } : undefined,
66
+ from: process.env.EMAIL_FROM || "noreply@example.com",
67
+ baseUrl: process.env.PUBLIC_BASE_URL || "http://localhost:5173",
68
+ }));
69
+
59
70
  server.registerPlugin(demoPlugin);
60
71
  server.registerPlugin(workflowDemoPlugin);
61
72
 
@@ -0,0 +1,60 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ // Passkey credentials (WebAuthn)
5
+ await db.schema
6
+ .createTable("passkeys")
7
+ .ifNotExists()
8
+ .addColumn("id", "text", (col) => col.primaryKey())
9
+ .addColumn("user_id", "text", (col) =>
10
+ col.notNull().references("users.id").onDelete("cascade")
11
+ )
12
+ .addColumn("credential_id", "text", (col) => col.notNull().unique())
13
+ .addColumn("public_key", "text", (col) => col.notNull()) // Base64 encoded
14
+ .addColumn("counter", "integer", (col) => col.notNull().defaultTo(0))
15
+ .addColumn("device_type", "text") // platform, cross-platform
16
+ .addColumn("backed_up", "integer", (col) => col.notNull().defaultTo(0))
17
+ .addColumn("transports", "text") // JSON array
18
+ .addColumn("name", "text") // User-friendly name
19
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
20
+ .addColumn("last_used_at", "text")
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("idx_passkeys_user_id")
25
+ .ifNotExists()
26
+ .on("passkeys")
27
+ .column("user_id")
28
+ .execute();
29
+
30
+ await db.schema
31
+ .createIndex("idx_passkeys_credential_id")
32
+ .ifNotExists()
33
+ .on("passkeys")
34
+ .column("credential_id")
35
+ .execute();
36
+
37
+ // Passkey challenges (temporary storage)
38
+ await db.schema
39
+ .createTable("passkey_challenges")
40
+ .ifNotExists()
41
+ .addColumn("id", "text", (col) => col.primaryKey())
42
+ .addColumn("challenge", "text", (col) => col.notNull())
43
+ .addColumn("user_id", "text") // Null for registration
44
+ .addColumn("type", "text", (col) => col.notNull()) // registration, authentication
45
+ .addColumn("expires_at", "text", (col) => col.notNull())
46
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
47
+ .execute();
48
+
49
+ await db.schema
50
+ .createIndex("idx_passkey_challenges_expires_at")
51
+ .ifNotExists()
52
+ .on("passkey_challenges")
53
+ .column("expires_at")
54
+ .execute();
55
+ }
56
+
57
+ export async function down(db: Kysely<any>): Promise<void> {
58
+ await db.schema.dropTable("passkey_challenges").ifExists().execute();
59
+ await db.schema.dropTable("passkeys").ifExists().execute();
60
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Email Plugin - Transactional email with multiple providers
3
+ *
4
+ * Providers:
5
+ * - resend: Resend.com API
6
+ * - console: Log emails to console (development)
7
+ *
8
+ * Features:
9
+ * - Magic link generation and validation
10
+ * - Password reset tokens
11
+ * - Email verification
12
+ * - Template support
13
+ */
14
+
15
+ import { createPlugin } from "@donkeylabs/server";
16
+ import type { ColumnType } from "kysely";
17
+
18
+ // =============================================================================
19
+ // CONFIGURATION TYPES
20
+ // =============================================================================
21
+
22
+ export type EmailProvider = "resend" | "console";
23
+
24
+ export interface EmailConfig {
25
+ /**
26
+ * Email provider
27
+ * @default "console"
28
+ */
29
+ provider?: EmailProvider;
30
+
31
+ /**
32
+ * Provider-specific configuration
33
+ */
34
+ resend?: {
35
+ apiKey: string;
36
+ };
37
+
38
+ /**
39
+ * Default from address
40
+ */
41
+ from: string;
42
+
43
+ /**
44
+ * Base URL for links (e.g., "https://myapp.com")
45
+ */
46
+ baseUrl: string;
47
+
48
+ /**
49
+ * Token expiry times
50
+ */
51
+ expiry?: {
52
+ /** Magic link expiry @default "15m" */
53
+ magicLink?: string;
54
+ /** Password reset expiry @default "1h" */
55
+ passwordReset?: string;
56
+ /** Email verification expiry @default "24h" */
57
+ emailVerification?: string;
58
+ };
59
+ }
60
+
61
+ // =============================================================================
62
+ // DATABASE SCHEMA
63
+ // =============================================================================
64
+
65
+ interface EmailTokensTable {
66
+ id: string;
67
+ type: "magic_link" | "password_reset" | "email_verification";
68
+ email: string;
69
+ token_hash: string;
70
+ expires_at: string;
71
+ used_at: string | null;
72
+ created_at: ColumnType<string, string | undefined, never>;
73
+ }
74
+
75
+ interface EmailSchema {
76
+ email_tokens: EmailTokensTable;
77
+ }
78
+
79
+ // =============================================================================
80
+ // TYPES
81
+ // =============================================================================
82
+
83
+ export interface SendEmailOptions {
84
+ to: string | string[];
85
+ subject: string;
86
+ html?: string;
87
+ text?: string;
88
+ from?: string;
89
+ replyTo?: string;
90
+ }
91
+
92
+ export interface EmailResult {
93
+ success: boolean;
94
+ messageId?: string;
95
+ error?: string;
96
+ }
97
+
98
+ // =============================================================================
99
+ // HELPERS
100
+ // =============================================================================
101
+
102
+ function parseExpiry(expiry: string): number {
103
+ const match = expiry.match(/^(\d+)(s|m|h|d)$/);
104
+ if (!match) return 15 * 60 * 1000; // Default 15 minutes
105
+
106
+ const value = parseInt(match[1]);
107
+ const unit = match[2];
108
+
109
+ switch (unit) {
110
+ case "s": return value * 1000;
111
+ case "m": return value * 60 * 1000;
112
+ case "h": return value * 60 * 60 * 1000;
113
+ case "d": return value * 24 * 60 * 60 * 1000;
114
+ default: return 15 * 60 * 1000;
115
+ }
116
+ }
117
+
118
+ function generateToken(): string {
119
+ const bytes = new Uint8Array(32);
120
+ crypto.getRandomValues(bytes);
121
+ return Array.from(bytes)
122
+ .map((b) => b.toString(16).padStart(2, "0"))
123
+ .join("");
124
+ }
125
+
126
+ // =============================================================================
127
+ // PLUGIN DEFINITION
128
+ // =============================================================================
129
+
130
+ export const emailPlugin = createPlugin
131
+ .withSchema<EmailSchema>()
132
+ .withConfig<EmailConfig>()
133
+ .define({
134
+ name: "email",
135
+
136
+ customErrors: {
137
+ InvalidToken: {
138
+ status: 400,
139
+ code: "INVALID_TOKEN",
140
+ message: "Invalid or expired token",
141
+ },
142
+ TokenAlreadyUsed: {
143
+ status: 400,
144
+ code: "TOKEN_USED",
145
+ message: "This token has already been used",
146
+ },
147
+ SendFailed: {
148
+ status: 500,
149
+ code: "EMAIL_SEND_FAILED",
150
+ message: "Failed to send email",
151
+ },
152
+ },
153
+
154
+ service: async (ctx) => {
155
+ const config = ctx.config!;
156
+ const provider = config.provider || "console";
157
+ const from = config.from;
158
+ const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
159
+
160
+ const magicLinkExpiry = parseExpiry(config.expiry?.magicLink || "15m");
161
+ const passwordResetExpiry = parseExpiry(config.expiry?.passwordReset || "1h");
162
+ const emailVerificationExpiry = parseExpiry(config.expiry?.emailVerification || "24h");
163
+
164
+ /**
165
+ * Send email using configured provider
166
+ */
167
+ async function sendEmail(options: SendEmailOptions): Promise<EmailResult> {
168
+ const emailFrom = options.from || from;
169
+ const recipients = Array.isArray(options.to) ? options.to : [options.to];
170
+
171
+ if (provider === "console") {
172
+ ctx.core.logger.info("Email sent (console provider)", {
173
+ to: recipients,
174
+ subject: options.subject,
175
+ from: emailFrom,
176
+ });
177
+ console.log("\n" + "=".repeat(60));
178
+ console.log(`📧 EMAIL TO: ${recipients.join(", ")}`);
179
+ console.log(` FROM: ${emailFrom}`);
180
+ console.log(` SUBJECT: ${options.subject}`);
181
+ console.log("-".repeat(60));
182
+ console.log(options.text || options.html);
183
+ console.log("=".repeat(60) + "\n");
184
+ return { success: true, messageId: `console-${Date.now()}` };
185
+ }
186
+
187
+ if (provider === "resend") {
188
+ if (!config.resend?.apiKey) {
189
+ throw new Error("Resend API key not configured");
190
+ }
191
+
192
+ try {
193
+ const response = await fetch("https://api.resend.com/emails", {
194
+ method: "POST",
195
+ headers: {
196
+ "Authorization": `Bearer ${config.resend.apiKey}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify({
200
+ from: emailFrom,
201
+ to: recipients,
202
+ subject: options.subject,
203
+ html: options.html,
204
+ text: options.text,
205
+ reply_to: options.replyTo,
206
+ }),
207
+ });
208
+
209
+ if (!response.ok) {
210
+ const error = await response.text();
211
+ ctx.core.logger.error("Resend API error", { error, status: response.status });
212
+ return { success: false, error };
213
+ }
214
+
215
+ const data = await response.json() as { id: string };
216
+ return { success: true, messageId: data.id };
217
+ } catch (error) {
218
+ ctx.core.logger.error("Failed to send email via Resend", { error });
219
+ return { success: false, error: String(error) };
220
+ }
221
+ }
222
+
223
+ return { success: false, error: `Unknown provider: ${provider}` };
224
+ }
225
+
226
+ /**
227
+ * Create a token and store hash
228
+ */
229
+ async function createToken(
230
+ type: "magic_link" | "password_reset" | "email_verification",
231
+ email: string,
232
+ expiryMs: number
233
+ ): Promise<string> {
234
+ const token = generateToken();
235
+ const tokenHash = await Bun.password.hash(token, {
236
+ algorithm: "bcrypt",
237
+ cost: 4, // Low cost for tokens
238
+ });
239
+
240
+ await ctx.db
241
+ .insertInto("email_tokens")
242
+ .values({
243
+ id: crypto.randomUUID(),
244
+ type,
245
+ email: email.toLowerCase(),
246
+ token_hash: tokenHash,
247
+ expires_at: new Date(Date.now() + expiryMs).toISOString(),
248
+ used_at: null,
249
+ created_at: new Date().toISOString(),
250
+ })
251
+ .execute();
252
+
253
+ return token;
254
+ }
255
+
256
+ /**
257
+ * Validate and consume a token
258
+ */
259
+ async function validateToken(
260
+ type: "magic_link" | "password_reset" | "email_verification",
261
+ email: string,
262
+ token: string
263
+ ): Promise<boolean> {
264
+ const tokens = await ctx.db
265
+ .selectFrom("email_tokens")
266
+ .where("type", "=", type)
267
+ .where("email", "=", email.toLowerCase())
268
+ .where("used_at", "is", null)
269
+ .where("expires_at", ">", new Date().toISOString())
270
+ .selectAll()
271
+ .execute();
272
+
273
+ for (const stored of tokens) {
274
+ const valid = await Bun.password.verify(token, stored.token_hash);
275
+ if (valid) {
276
+ // Mark as used
277
+ await ctx.db
278
+ .updateTable("email_tokens")
279
+ .set({ used_at: new Date().toISOString() })
280
+ .where("id", "=", stored.id)
281
+ .execute();
282
+ return true;
283
+ }
284
+ }
285
+
286
+ return false;
287
+ }
288
+
289
+ return {
290
+ /**
291
+ * Send a raw email
292
+ */
293
+ send: sendEmail,
294
+
295
+ /**
296
+ * Send magic link for passwordless login
297
+ */
298
+ sendMagicLink: async (email: string, redirectPath: string = "/"): Promise<EmailResult> => {
299
+ const token = await createToken("magic_link", email, magicLinkExpiry);
300
+ const magicLink = `${baseUrl}/auth/magic?token=${token}&email=${encodeURIComponent(email)}&redirect=${encodeURIComponent(redirectPath)}`;
301
+
302
+ return sendEmail({
303
+ to: email,
304
+ subject: "Sign in to your account",
305
+ html: `
306
+ <h2>Sign in to your account</h2>
307
+ <p>Click the link below to sign in. This link expires in 15 minutes.</p>
308
+ <p><a href="${magicLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Sign In</a></p>
309
+ <p style="color: #666; font-size: 14px;">Or copy this link: ${magicLink}</p>
310
+ <p style="color: #999; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
311
+ `,
312
+ text: `Sign in to your account\n\nClick here to sign in: ${magicLink}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
313
+ });
314
+ },
315
+
316
+ /**
317
+ * Validate magic link token and return email if valid
318
+ */
319
+ validateMagicLink: async (email: string, token: string): Promise<boolean> => {
320
+ return validateToken("magic_link", email, token);
321
+ },
322
+
323
+ /**
324
+ * Send password reset email
325
+ */
326
+ sendPasswordReset: async (email: string): Promise<EmailResult> => {
327
+ const token = await createToken("password_reset", email, passwordResetExpiry);
328
+ const resetLink = `${baseUrl}/auth/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
329
+
330
+ return sendEmail({
331
+ to: email,
332
+ subject: "Reset your password",
333
+ html: `
334
+ <h2>Reset your password</h2>
335
+ <p>Click the link below to reset your password. This link expires in 1 hour.</p>
336
+ <p><a href="${resetLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Reset Password</a></p>
337
+ <p style="color: #666; font-size: 14px;">Or copy this link: ${resetLink}</p>
338
+ <p style="color: #999; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
339
+ `,
340
+ text: `Reset your password\n\nClick here to reset: ${resetLink}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, you can safely ignore this email.`,
341
+ });
342
+ },
343
+
344
+ /**
345
+ * Validate password reset token
346
+ */
347
+ validatePasswordReset: async (email: string, token: string): Promise<boolean> => {
348
+ return validateToken("password_reset", email, token);
349
+ },
350
+
351
+ /**
352
+ * Send email verification
353
+ */
354
+ sendVerification: async (email: string): Promise<EmailResult> => {
355
+ const token = await createToken("email_verification", email, emailVerificationExpiry);
356
+ const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
357
+
358
+ return sendEmail({
359
+ to: email,
360
+ subject: "Verify your email address",
361
+ html: `
362
+ <h2>Verify your email address</h2>
363
+ <p>Click the link below to verify your email. This link expires in 24 hours.</p>
364
+ <p><a href="${verifyLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Verify Email</a></p>
365
+ <p style="color: #666; font-size: 14px;">Or copy this link: ${verifyLink}</p>
366
+ <p style="color: #999; font-size: 12px;">If you didn't create an account, you can safely ignore this email.</p>
367
+ `,
368
+ text: `Verify your email address\n\nClick here to verify: ${verifyLink}\n\nThis link expires in 24 hours.\n\nIf you didn't create an account, you can safely ignore this email.`,
369
+ });
370
+ },
371
+
372
+ /**
373
+ * Validate email verification token
374
+ */
375
+ validateVerification: async (email: string, token: string): Promise<boolean> => {
376
+ return validateToken("email_verification", email, token);
377
+ },
378
+
379
+ /**
380
+ * Cleanup expired tokens
381
+ */
382
+ cleanup: async (): Promise<number> => {
383
+ const result = await ctx.db
384
+ .deleteFrom("email_tokens")
385
+ .where("expires_at", "<", new Date().toISOString())
386
+ .executeTakeFirst();
387
+
388
+ const count = Number(result.numDeletedRows);
389
+ if (count > 0) {
390
+ ctx.core.logger.info("Cleaned up expired email tokens", { count });
391
+ }
392
+ return count;
393
+ },
394
+ };
395
+ },
396
+
397
+ init: async (ctx, service) => {
398
+ // Cleanup expired tokens daily
399
+ ctx.core.cron.schedule(
400
+ "0 4 * * *", // Daily at 4am
401
+ async () => {
402
+ await service.cleanup();
403
+ },
404
+ { name: "email-token-cleanup" }
405
+ );
406
+
407
+ ctx.core.logger.info("Email plugin initialized", {
408
+ provider: ctx.config?.provider || "console",
409
+ });
410
+ },
411
+ });
@@ -0,0 +1,33 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ await db.schema
5
+ .createTable("email_tokens")
6
+ .ifNotExists()
7
+ .addColumn("id", "text", (col) => col.primaryKey())
8
+ .addColumn("type", "text", (col) => col.notNull()) // magic_link, password_reset, email_verification
9
+ .addColumn("email", "text", (col) => col.notNull())
10
+ .addColumn("token_hash", "text", (col) => col.notNull())
11
+ .addColumn("expires_at", "text", (col) => col.notNull())
12
+ .addColumn("used_at", "text")
13
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
14
+ .execute();
15
+
16
+ await db.schema
17
+ .createIndex("idx_email_tokens_email_type")
18
+ .ifNotExists()
19
+ .on("email_tokens")
20
+ .columns(["email", "type"])
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("idx_email_tokens_expires_at")
25
+ .ifNotExists()
26
+ .on("email_tokens")
27
+ .column("expires_at")
28
+ .execute();
29
+ }
30
+
31
+ export async function down(db: Kysely<any>): Promise<void> {
32
+ await db.schema.dropTable("email_tokens").ifExists().execute();
33
+ }