@donkeylabs/cli 1.1.13 → 1.1.15

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.13",
3
+ "version": "1.1.15",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -5,8 +5,10 @@ import { BunSqliteDialect } from "kysely-bun-sqlite";
5
5
  import { Database } from "bun:sqlite";
6
6
  import { demoPlugin } from "./plugins/demo";
7
7
  import { workflowDemoPlugin } from "./plugins/workflow-demo";
8
+ import { authPlugin } from "./plugins/auth";
8
9
  import demoRoutes from "./routes/demo";
9
10
  import { exampleRouter } from "./routes/example";
11
+ import { authRouter } from "./routes/auth";
10
12
 
11
13
  // Simple in-memory database
12
14
  const db = new Kysely<{}>({
@@ -22,11 +24,43 @@ export const server = new AppServer({
22
24
  },
23
25
  });
24
26
 
25
- // Register plugins
27
+ // =============================================================================
28
+ // AUTH PLUGIN CONFIGURATION
29
+ // =============================================================================
30
+ // Choose your auth strategy:
31
+ //
32
+ // 1. SESSION (default) - Stateful, stores sessions in database
33
+ // Best for: Web apps, server-rendered pages
34
+ // server.registerPlugin(authPlugin());
35
+ //
36
+ // 2. JWT - Stateless tokens, no database lookup needed
37
+ // Best for: Mobile apps, microservices, APIs
38
+ // server.registerPlugin(authPlugin({
39
+ // strategy: "jwt",
40
+ // jwt: { secret: process.env.JWT_SECRET! },
41
+ // }));
42
+ //
43
+ // 3. REFRESH-TOKEN - Short-lived access + long-lived refresh token
44
+ // Best for: SPAs, mobile apps needing token refresh
45
+ // server.registerPlugin(authPlugin({
46
+ // strategy: "refresh-token",
47
+ // jwt: {
48
+ // secret: process.env.JWT_SECRET!,
49
+ // accessExpiry: "15m",
50
+ // refreshExpiry: "30d",
51
+ // },
52
+ // cookie: { httpOnly: true, secure: true },
53
+ // }));
54
+ //
55
+ // =============================================================================
56
+
57
+ // Using default session strategy for this template
58
+ server.registerPlugin(authPlugin());
26
59
  server.registerPlugin(demoPlugin);
27
60
  server.registerPlugin(workflowDemoPlugin);
28
61
 
29
62
  // Register routes
63
+ server.use(authRouter);
30
64
  server.use(demoRoutes);
31
65
  server.use(exampleRouter);
32
66
 
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Auth Plugin - Configurable authentication with multiple strategies
3
+ *
4
+ * Strategies:
5
+ * - session: Stateful, database sessions (default)
6
+ * - jwt: Stateless JWT tokens
7
+ * - refresh-token: Access token + refresh token pattern
8
+ *
9
+ * Storage:
10
+ * - cookie: HTTP-only cookies (recommended for web)
11
+ * - header: Authorization header (for APIs/mobile)
12
+ * - both: Support both methods
13
+ */
14
+
15
+ import { createPlugin, createMiddleware } from "@donkeylabs/server";
16
+ import type { ColumnType } from "kysely";
17
+
18
+ // =============================================================================
19
+ // CONFIGURATION TYPES
20
+ // =============================================================================
21
+
22
+ export type AuthStrategy = "session" | "jwt" | "refresh-token";
23
+ export type TokenStorage = "cookie" | "header" | "both";
24
+
25
+ export interface AuthConfig {
26
+ /**
27
+ * Authentication strategy
28
+ * - session: Stateful, stores sessions in database
29
+ * - jwt: Stateless, token contains user info
30
+ * - refresh-token: Short-lived access + long-lived refresh token
31
+ * @default "session"
32
+ */
33
+ strategy?: AuthStrategy;
34
+
35
+ /**
36
+ * How tokens/sessions are transmitted
37
+ * - cookie: HTTP-only cookies (secure for web apps)
38
+ * - header: Authorization header (for APIs/mobile)
39
+ * - both: Accept both methods
40
+ * @default "both"
41
+ */
42
+ storage?: TokenStorage;
43
+
44
+ /**
45
+ * Cookie configuration (when storage includes cookies)
46
+ */
47
+ cookie?: {
48
+ /** Cookie name prefix @default "auth" */
49
+ name?: string;
50
+ /** HTTP-only flag @default true */
51
+ httpOnly?: boolean;
52
+ /** Secure flag (HTTPS only) @default true in production */
53
+ secure?: boolean;
54
+ /** SameSite policy @default "lax" */
55
+ sameSite?: "strict" | "lax" | "none";
56
+ /** Cookie path @default "/" */
57
+ path?: string;
58
+ /** Cookie domain (optional) */
59
+ domain?: string;
60
+ };
61
+
62
+ /**
63
+ * JWT configuration (for jwt and refresh-token strategies)
64
+ */
65
+ jwt?: {
66
+ /** Secret key for signing tokens (required for jwt/refresh-token) */
67
+ secret: string;
68
+ /** Access token expiry @default "15m" for refresh-token, "7d" for jwt */
69
+ accessExpiry?: string;
70
+ /** Refresh token expiry @default "30d" (refresh-token strategy only) */
71
+ refreshExpiry?: string;
72
+ /** Token issuer (optional) */
73
+ issuer?: string;
74
+ };
75
+
76
+ /**
77
+ * Session configuration (for session strategy)
78
+ */
79
+ session?: {
80
+ /** Session duration @default "7d" */
81
+ expiry?: string;
82
+ };
83
+
84
+ /**
85
+ * Password hashing cost @default 10
86
+ */
87
+ bcryptCost?: number;
88
+ }
89
+
90
+ // =============================================================================
91
+ // DATABASE SCHEMA TYPES
92
+ // =============================================================================
93
+
94
+ interface UsersTable {
95
+ id: string;
96
+ email: string;
97
+ password_hash: string;
98
+ name: string | null;
99
+ created_at: ColumnType<string, string | undefined, never>;
100
+ updated_at: string;
101
+ }
102
+
103
+ interface SessionsTable {
104
+ id: string;
105
+ user_id: string;
106
+ expires_at: string;
107
+ created_at: ColumnType<string, string | undefined, never>;
108
+ }
109
+
110
+ interface RefreshTokensTable {
111
+ id: string;
112
+ user_id: string;
113
+ token_hash: string;
114
+ expires_at: string;
115
+ created_at: ColumnType<string, string | undefined, never>;
116
+ }
117
+
118
+ interface AuthSchema {
119
+ users: UsersTable;
120
+ sessions: SessionsTable;
121
+ refresh_tokens: RefreshTokensTable;
122
+ }
123
+
124
+ // =============================================================================
125
+ // EXPORTED TYPES
126
+ // =============================================================================
127
+
128
+ export interface AuthUser {
129
+ id: string;
130
+ email: string;
131
+ name: string | null;
132
+ }
133
+
134
+ export interface AuthTokens {
135
+ accessToken: string;
136
+ refreshToken?: string;
137
+ expiresIn: number;
138
+ }
139
+
140
+ export interface AuthResult {
141
+ user: AuthUser;
142
+ tokens: AuthTokens;
143
+ }
144
+
145
+ // =============================================================================
146
+ // HELPERS
147
+ // =============================================================================
148
+
149
+ function parseExpiry(expiry: string): number {
150
+ const match = expiry.match(/^(\d+)(s|m|h|d)$/);
151
+ if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
152
+
153
+ const value = parseInt(match[1]);
154
+ const unit = match[2];
155
+
156
+ switch (unit) {
157
+ case "s": return value * 1000;
158
+ case "m": return value * 60 * 1000;
159
+ case "h": return value * 60 * 60 * 1000;
160
+ case "d": return value * 24 * 60 * 60 * 1000;
161
+ default: return 7 * 24 * 60 * 60 * 1000;
162
+ }
163
+ }
164
+
165
+ interface JWTPayload {
166
+ sub: string;
167
+ email: string;
168
+ name: string | null;
169
+ iat: number;
170
+ exp: number;
171
+ iss?: string;
172
+ type?: "access" | "refresh";
173
+ }
174
+
175
+ function base64UrlEncode(data: string): string {
176
+ return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
177
+ }
178
+
179
+ function base64UrlDecode(data: string): string {
180
+ const padded = data + "=".repeat((4 - (data.length % 4)) % 4);
181
+ return atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
182
+ }
183
+
184
+ async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
185
+ const header = { alg: "HS256", typ: "JWT" };
186
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
187
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
188
+
189
+ const data = `${encodedHeader}.${encodedPayload}`;
190
+ const encoder = new TextEncoder();
191
+ const key = await crypto.subtle.importKey(
192
+ "raw",
193
+ encoder.encode(secret),
194
+ { name: "HMAC", hash: "SHA-256" },
195
+ false,
196
+ ["sign"]
197
+ );
198
+
199
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
200
+ const encodedSignature = base64UrlEncode(
201
+ String.fromCharCode(...new Uint8Array(signature))
202
+ );
203
+
204
+ return `${data}.${encodedSignature}`;
205
+ }
206
+
207
+ async function verifyJWT(token: string, secret: string): Promise<JWTPayload | null> {
208
+ try {
209
+ const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
210
+ if (!encodedHeader || !encodedPayload || !encodedSignature) return null;
211
+
212
+ const data = `${encodedHeader}.${encodedPayload}`;
213
+ const encoder = new TextEncoder();
214
+ const key = await crypto.subtle.importKey(
215
+ "raw",
216
+ encoder.encode(secret),
217
+ { name: "HMAC", hash: "SHA-256" },
218
+ false,
219
+ ["verify"]
220
+ );
221
+
222
+ const signature = Uint8Array.from(
223
+ base64UrlDecode(encodedSignature),
224
+ (c) => c.charCodeAt(0)
225
+ );
226
+
227
+ const valid = await crypto.subtle.verify(
228
+ "HMAC",
229
+ key,
230
+ signature,
231
+ encoder.encode(data)
232
+ );
233
+
234
+ if (!valid) return null;
235
+
236
+ const payload: JWTPayload = JSON.parse(base64UrlDecode(encodedPayload));
237
+
238
+ // Check expiry
239
+ if (payload.exp * 1000 < Date.now()) return null;
240
+
241
+ return payload;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ // =============================================================================
248
+ // PLUGIN DEFINITION
249
+ // =============================================================================
250
+
251
+ export const authPlugin = createPlugin
252
+ .withSchema<AuthSchema>()
253
+ .withConfig<AuthConfig>()
254
+ .define({
255
+ name: "auth",
256
+
257
+ customErrors: {
258
+ InvalidCredentials: {
259
+ status: 401,
260
+ code: "INVALID_CREDENTIALS",
261
+ message: "Invalid email or password",
262
+ },
263
+ EmailAlreadyExists: {
264
+ status: 409,
265
+ code: "EMAIL_EXISTS",
266
+ message: "An account with this email already exists",
267
+ },
268
+ InvalidToken: {
269
+ status: 401,
270
+ code: "INVALID_TOKEN",
271
+ message: "Invalid or expired token",
272
+ },
273
+ RefreshTokenExpired: {
274
+ status: 401,
275
+ code: "REFRESH_TOKEN_EXPIRED",
276
+ message: "Refresh token has expired. Please log in again.",
277
+ },
278
+ },
279
+
280
+ service: async (ctx) => {
281
+ const config = ctx.config || {};
282
+ const strategy = config.strategy || "session";
283
+ const storage = config.storage || "both";
284
+ const bcryptCost = config.bcryptCost || 10;
285
+
286
+ // Expiry defaults based on strategy
287
+ const accessExpiryMs = parseExpiry(
288
+ config.jwt?.accessExpiry ||
289
+ (strategy === "refresh-token" ? "15m" : "7d")
290
+ );
291
+ const refreshExpiryMs = parseExpiry(config.jwt?.refreshExpiry || "30d");
292
+ const sessionExpiryMs = parseExpiry(config.session?.expiry || "7d");
293
+
294
+ const jwtSecret = config.jwt?.secret || "";
295
+
296
+ // Validate config
297
+ if ((strategy === "jwt" || strategy === "refresh-token") && !jwtSecret) {
298
+ throw new Error("Auth plugin: jwt.secret is required for jwt/refresh-token strategy");
299
+ }
300
+
301
+ /**
302
+ * Create tokens based on strategy
303
+ */
304
+ async function createTokens(user: AuthUser): Promise<AuthTokens> {
305
+ const now = Math.floor(Date.now() / 1000);
306
+
307
+ if (strategy === "jwt") {
308
+ const payload: JWTPayload = {
309
+ sub: user.id,
310
+ email: user.email,
311
+ name: user.name,
312
+ iat: now,
313
+ exp: now + Math.floor(accessExpiryMs / 1000),
314
+ iss: config.jwt?.issuer,
315
+ };
316
+
317
+ const accessToken = await signJWT(payload, jwtSecret);
318
+ return {
319
+ accessToken,
320
+ expiresIn: Math.floor(accessExpiryMs / 1000),
321
+ };
322
+ }
323
+
324
+ if (strategy === "refresh-token") {
325
+ // Access token (short-lived)
326
+ const accessPayload: JWTPayload = {
327
+ sub: user.id,
328
+ email: user.email,
329
+ name: user.name,
330
+ iat: now,
331
+ exp: now + Math.floor(accessExpiryMs / 1000),
332
+ iss: config.jwt?.issuer,
333
+ type: "access",
334
+ };
335
+
336
+ // Refresh token (long-lived, stored in DB)
337
+ const refreshTokenId = crypto.randomUUID();
338
+ const refreshPayload: JWTPayload = {
339
+ sub: user.id,
340
+ email: user.email,
341
+ name: user.name,
342
+ iat: now,
343
+ exp: now + Math.floor(refreshExpiryMs / 1000),
344
+ iss: config.jwt?.issuer,
345
+ type: "refresh",
346
+ };
347
+
348
+ const accessToken = await signJWT(accessPayload, jwtSecret);
349
+ const refreshToken = await signJWT(refreshPayload, jwtSecret);
350
+
351
+ // Hash and store refresh token
352
+ const tokenHash = await Bun.password.hash(refreshToken, {
353
+ algorithm: "bcrypt",
354
+ cost: 4, // Lower cost for refresh tokens (checked less often)
355
+ });
356
+
357
+ await ctx.db
358
+ .insertInto("refresh_tokens")
359
+ .values({
360
+ id: refreshTokenId,
361
+ user_id: user.id,
362
+ token_hash: tokenHash,
363
+ expires_at: new Date(Date.now() + refreshExpiryMs).toISOString(),
364
+ created_at: new Date().toISOString(),
365
+ })
366
+ .execute();
367
+
368
+ return {
369
+ accessToken,
370
+ refreshToken,
371
+ expiresIn: Math.floor(accessExpiryMs / 1000),
372
+ };
373
+ }
374
+
375
+ // Session strategy - create DB session
376
+ const sessionId = crypto.randomUUID();
377
+ const expiresAt = new Date(Date.now() + sessionExpiryMs);
378
+
379
+ await ctx.db
380
+ .insertInto("sessions")
381
+ .values({
382
+ id: sessionId,
383
+ user_id: user.id,
384
+ expires_at: expiresAt.toISOString(),
385
+ created_at: new Date().toISOString(),
386
+ })
387
+ .execute();
388
+
389
+ return {
390
+ accessToken: sessionId,
391
+ expiresIn: Math.floor(sessionExpiryMs / 1000),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Validate token and return user
397
+ */
398
+ async function validateToken(token: string): Promise<AuthUser | null> {
399
+ if (strategy === "jwt" || strategy === "refresh-token") {
400
+ const payload = await verifyJWT(token, jwtSecret);
401
+ if (!payload) return null;
402
+
403
+ // For refresh-token strategy, only accept access tokens in middleware
404
+ if (strategy === "refresh-token" && payload.type !== "access") {
405
+ return null;
406
+ }
407
+
408
+ return {
409
+ id: payload.sub,
410
+ email: payload.email,
411
+ name: payload.name,
412
+ };
413
+ }
414
+
415
+ // Session strategy - lookup in DB
416
+ const session = await ctx.db
417
+ .selectFrom("sessions")
418
+ .where("id", "=", token)
419
+ .selectAll()
420
+ .executeTakeFirst();
421
+
422
+ if (!session) return null;
423
+
424
+ if (new Date(session.expires_at) < new Date()) {
425
+ await ctx.db.deleteFrom("sessions").where("id", "=", token).execute();
426
+ return null;
427
+ }
428
+
429
+ const user = await ctx.db
430
+ .selectFrom("users")
431
+ .where("id", "=", session.user_id)
432
+ .selectAll()
433
+ .executeTakeFirst();
434
+
435
+ if (!user) return null;
436
+
437
+ return {
438
+ id: user.id,
439
+ email: user.email,
440
+ name: user.name,
441
+ };
442
+ }
443
+
444
+ return {
445
+ /** Get current strategy */
446
+ getStrategy: () => strategy,
447
+
448
+ /** Get storage type */
449
+ getStorage: () => storage,
450
+
451
+ /** Get cookie config */
452
+ getCookieConfig: () => ({
453
+ name: config.cookie?.name || "auth",
454
+ httpOnly: config.cookie?.httpOnly ?? true,
455
+ secure: config.cookie?.secure ?? process.env.NODE_ENV === "production",
456
+ sameSite: config.cookie?.sameSite || "lax",
457
+ path: config.cookie?.path || "/",
458
+ domain: config.cookie?.domain,
459
+ }),
460
+
461
+ /**
462
+ * Register a new user
463
+ */
464
+ register: async (data: {
465
+ email: string;
466
+ password: string;
467
+ name?: string;
468
+ }): Promise<AuthResult> => {
469
+ const { email, password, name } = data;
470
+
471
+ const existing = await ctx.db
472
+ .selectFrom("users")
473
+ .where("email", "=", email.toLowerCase())
474
+ .selectAll()
475
+ .executeTakeFirst();
476
+
477
+ if (existing) {
478
+ throw ctx.errors.EmailAlreadyExists();
479
+ }
480
+
481
+ const passwordHash = await Bun.password.hash(password, {
482
+ algorithm: "bcrypt",
483
+ cost: bcryptCost,
484
+ });
485
+
486
+ const userId = crypto.randomUUID();
487
+ const now = new Date().toISOString();
488
+
489
+ await ctx.db
490
+ .insertInto("users")
491
+ .values({
492
+ id: userId,
493
+ email: email.toLowerCase(),
494
+ password_hash: passwordHash,
495
+ name: name || null,
496
+ created_at: now,
497
+ updated_at: now,
498
+ })
499
+ .execute();
500
+
501
+ const user: AuthUser = {
502
+ id: userId,
503
+ email: email.toLowerCase(),
504
+ name: name || null,
505
+ };
506
+
507
+ const tokens = await createTokens(user);
508
+
509
+ ctx.core.logger.info("User registered", { userId, email, strategy });
510
+
511
+ return { user, tokens };
512
+ },
513
+
514
+ /**
515
+ * Login with email and password
516
+ */
517
+ login: async (data: {
518
+ email: string;
519
+ password: string;
520
+ }): Promise<AuthResult> => {
521
+ const { email, password } = data;
522
+
523
+ const dbUser = await ctx.db
524
+ .selectFrom("users")
525
+ .where("email", "=", email.toLowerCase())
526
+ .selectAll()
527
+ .executeTakeFirst();
528
+
529
+ if (!dbUser) {
530
+ throw ctx.errors.InvalidCredentials();
531
+ }
532
+
533
+ const valid = await Bun.password.verify(password, dbUser.password_hash);
534
+ if (!valid) {
535
+ throw ctx.errors.InvalidCredentials();
536
+ }
537
+
538
+ const user: AuthUser = {
539
+ id: dbUser.id,
540
+ email: dbUser.email,
541
+ name: dbUser.name,
542
+ };
543
+
544
+ const tokens = await createTokens(user);
545
+
546
+ ctx.core.logger.info("User logged in", { userId: user.id, strategy });
547
+
548
+ return { user, tokens };
549
+ },
550
+
551
+ /**
552
+ * Refresh access token (refresh-token strategy only)
553
+ */
554
+ refresh: async (refreshToken: string): Promise<AuthTokens> => {
555
+ if (strategy !== "refresh-token") {
556
+ throw new Error("refresh() only available with refresh-token strategy");
557
+ }
558
+
559
+ const payload = await verifyJWT(refreshToken, jwtSecret);
560
+ if (!payload || payload.type !== "refresh") {
561
+ throw ctx.errors.InvalidToken();
562
+ }
563
+
564
+ // Verify refresh token exists in DB (not revoked)
565
+ const stored = await ctx.db
566
+ .selectFrom("refresh_tokens")
567
+ .where("user_id", "=", payload.sub)
568
+ .selectAll()
569
+ .execute();
570
+
571
+ let validToken = false;
572
+ for (const t of stored) {
573
+ if (await Bun.password.verify(refreshToken, t.token_hash)) {
574
+ validToken = true;
575
+ break;
576
+ }
577
+ }
578
+
579
+ if (!validToken) {
580
+ throw ctx.errors.RefreshTokenExpired();
581
+ }
582
+
583
+ // Get user
584
+ const user = await ctx.db
585
+ .selectFrom("users")
586
+ .where("id", "=", payload.sub)
587
+ .selectAll()
588
+ .executeTakeFirst();
589
+
590
+ if (!user) {
591
+ throw ctx.errors.InvalidToken();
592
+ }
593
+
594
+ // Create new access token (keep same refresh token)
595
+ const now = Math.floor(Date.now() / 1000);
596
+ const accessPayload: JWTPayload = {
597
+ sub: user.id,
598
+ email: user.email,
599
+ name: user.name,
600
+ iat: now,
601
+ exp: now + Math.floor(accessExpiryMs / 1000),
602
+ iss: config.jwt?.issuer,
603
+ type: "access",
604
+ };
605
+
606
+ const accessToken = await signJWT(accessPayload, jwtSecret);
607
+
608
+ return {
609
+ accessToken,
610
+ expiresIn: Math.floor(accessExpiryMs / 1000),
611
+ };
612
+ },
613
+
614
+ /**
615
+ * Logout - invalidate session/refresh token
616
+ */
617
+ logout: async (token: string): Promise<void> => {
618
+ if (strategy === "session") {
619
+ await ctx.db.deleteFrom("sessions").where("id", "=", token).execute();
620
+ } else if (strategy === "refresh-token") {
621
+ // For refresh-token, we receive the refresh token to revoke
622
+ const payload = await verifyJWT(token, jwtSecret);
623
+ if (payload) {
624
+ // Delete all refresh tokens for this user (logout everywhere)
625
+ // Or could do selective deletion by matching hash
626
+ await ctx.db
627
+ .deleteFrom("refresh_tokens")
628
+ .where("user_id", "=", payload.sub)
629
+ .execute();
630
+ }
631
+ }
632
+ // JWT strategy: tokens are stateless, nothing to invalidate
633
+ },
634
+
635
+ /**
636
+ * Logout from all devices
637
+ */
638
+ logoutAll: async (userId: string): Promise<void> => {
639
+ if (strategy === "session") {
640
+ await ctx.db.deleteFrom("sessions").where("user_id", "=", userId).execute();
641
+ } else if (strategy === "refresh-token") {
642
+ await ctx.db.deleteFrom("refresh_tokens").where("user_id", "=", userId).execute();
643
+ }
644
+ },
645
+
646
+ /**
647
+ * Validate token/session and get user
648
+ */
649
+ validateToken,
650
+
651
+ /**
652
+ * Get user by ID
653
+ */
654
+ getUserById: async (userId: string): Promise<AuthUser | null> => {
655
+ const user = await ctx.db
656
+ .selectFrom("users")
657
+ .where("id", "=", userId)
658
+ .selectAll()
659
+ .executeTakeFirst();
660
+
661
+ if (!user) return null;
662
+
663
+ return {
664
+ id: user.id,
665
+ email: user.email,
666
+ name: user.name,
667
+ };
668
+ },
669
+
670
+ /**
671
+ * Update user profile
672
+ */
673
+ updateProfile: async (
674
+ userId: string,
675
+ data: { name?: string; email?: string }
676
+ ): Promise<AuthUser> => {
677
+ const updates: Record<string, string> = {
678
+ updated_at: new Date().toISOString(),
679
+ };
680
+
681
+ if (data.name !== undefined) {
682
+ updates.name = data.name;
683
+ }
684
+
685
+ if (data.email !== undefined) {
686
+ const existing = await ctx.db
687
+ .selectFrom("users")
688
+ .where("email", "=", data.email.toLowerCase())
689
+ .where("id", "!=", userId)
690
+ .selectAll()
691
+ .executeTakeFirst();
692
+
693
+ if (existing) {
694
+ throw ctx.errors.EmailAlreadyExists();
695
+ }
696
+
697
+ updates.email = data.email.toLowerCase();
698
+ }
699
+
700
+ await ctx.db
701
+ .updateTable("users")
702
+ .set(updates)
703
+ .where("id", "=", userId)
704
+ .execute();
705
+
706
+ const user = await ctx.db
707
+ .selectFrom("users")
708
+ .where("id", "=", userId)
709
+ .selectAll()
710
+ .executeTakeFirstOrThrow();
711
+
712
+ return {
713
+ id: user.id,
714
+ email: user.email,
715
+ name: user.name,
716
+ };
717
+ },
718
+
719
+ /**
720
+ * Cleanup expired sessions/tokens
721
+ */
722
+ cleanup: async (): Promise<number> => {
723
+ const now = new Date().toISOString();
724
+ let count = 0;
725
+
726
+ if (strategy === "session") {
727
+ const result = await ctx.db
728
+ .deleteFrom("sessions")
729
+ .where("expires_at", "<", now)
730
+ .executeTakeFirst();
731
+ count = Number(result.numDeletedRows);
732
+ } else if (strategy === "refresh-token") {
733
+ const result = await ctx.db
734
+ .deleteFrom("refresh_tokens")
735
+ .where("expires_at", "<", now)
736
+ .executeTakeFirst();
737
+ count = Number(result.numDeletedRows);
738
+ }
739
+
740
+ if (count > 0) {
741
+ ctx.core.logger.info("Auth cleanup completed", { count, strategy });
742
+ }
743
+ return count;
744
+ },
745
+ };
746
+ },
747
+
748
+ middleware: (ctx, service) => ({
749
+ /**
750
+ * Auth middleware - validates token from cookie/header
751
+ */
752
+ auth: createMiddleware<{ required?: boolean }>(
753
+ async (req, reqCtx, next, middlewareConfig) => {
754
+ const storage = service.getStorage();
755
+ const cookieConfig = service.getCookieConfig();
756
+
757
+ let token: string | null = null;
758
+
759
+ // Try cookie first (if enabled)
760
+ if (storage === "cookie" || storage === "both") {
761
+ const cookies = req.headers.get("cookie") || "";
762
+ const cookieMatch = cookies.match(
763
+ new RegExp(`${cookieConfig.name}=([^;]+)`)
764
+ );
765
+ token = cookieMatch?.[1] || null;
766
+ }
767
+
768
+ // Try header (if enabled and no cookie found)
769
+ if (!token && (storage === "header" || storage === "both")) {
770
+ const authHeader = req.headers.get("authorization");
771
+ if (authHeader?.startsWith("Bearer ")) {
772
+ token = authHeader.slice(7);
773
+ }
774
+ }
775
+
776
+ if (token) {
777
+ const user = await service.validateToken(token);
778
+ if (user) {
779
+ (reqCtx as any).user = user;
780
+ (reqCtx as any).token = token;
781
+ }
782
+ }
783
+
784
+ if (middlewareConfig?.required && !(reqCtx as any).user) {
785
+ return Response.json(
786
+ { error: "Unauthorized", code: "UNAUTHORIZED" },
787
+ { status: 401 }
788
+ );
789
+ }
790
+
791
+ return next();
792
+ }
793
+ ),
794
+ }),
795
+
796
+ init: async (ctx, service) => {
797
+ const strategy = service.getStrategy();
798
+
799
+ // Schedule cleanup based on strategy
800
+ if (strategy === "session" || strategy === "refresh-token") {
801
+ ctx.core.cron.schedule(
802
+ "0 3 * * *", // Daily at 3am
803
+ async () => {
804
+ await service.cleanup();
805
+ },
806
+ { name: "auth-cleanup" }
807
+ );
808
+ }
809
+
810
+ ctx.core.logger.info("Auth plugin initialized", {
811
+ strategy,
812
+ storage: service.getStorage(),
813
+ });
814
+ },
815
+ });
@@ -0,0 +1,25 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ await db.schema
5
+ .createTable("users")
6
+ .ifNotExists()
7
+ .addColumn("id", "text", (col) => col.primaryKey())
8
+ .addColumn("email", "text", (col) => col.notNull().unique())
9
+ .addColumn("password_hash", "text", (col) => col.notNull())
10
+ .addColumn("name", "text")
11
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
12
+ .addColumn("updated_at", "text", (col) => col.notNull())
13
+ .execute();
14
+
15
+ await db.schema
16
+ .createIndex("idx_users_email")
17
+ .ifNotExists()
18
+ .on("users")
19
+ .column("email")
20
+ .execute();
21
+ }
22
+
23
+ export async function down(db: Kysely<any>): Promise<void> {
24
+ await db.schema.dropTable("users").ifExists().execute();
25
+ }
@@ -0,0 +1,32 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ await db.schema
5
+ .createTable("sessions")
6
+ .ifNotExists()
7
+ .addColumn("id", "text", (col) => col.primaryKey())
8
+ .addColumn("user_id", "text", (col) =>
9
+ col.notNull().references("users.id").onDelete("cascade")
10
+ )
11
+ .addColumn("expires_at", "text", (col) => col.notNull())
12
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
13
+ .execute();
14
+
15
+ await db.schema
16
+ .createIndex("idx_sessions_user_id")
17
+ .ifNotExists()
18
+ .on("sessions")
19
+ .column("user_id")
20
+ .execute();
21
+
22
+ await db.schema
23
+ .createIndex("idx_sessions_expires_at")
24
+ .ifNotExists()
25
+ .on("sessions")
26
+ .column("expires_at")
27
+ .execute();
28
+ }
29
+
30
+ export async function down(db: Kysely<any>): Promise<void> {
31
+ await db.schema.dropTable("sessions").ifExists().execute();
32
+ }
@@ -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("refresh_tokens")
6
+ .ifNotExists()
7
+ .addColumn("id", "text", (col) => col.primaryKey())
8
+ .addColumn("user_id", "text", (col) =>
9
+ col.notNull().references("users.id").onDelete("cascade")
10
+ )
11
+ .addColumn("token_hash", "text", (col) => col.notNull())
12
+ .addColumn("expires_at", "text", (col) => col.notNull())
13
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
14
+ .execute();
15
+
16
+ await db.schema
17
+ .createIndex("idx_refresh_tokens_user_id")
18
+ .ifNotExists()
19
+ .on("refresh_tokens")
20
+ .column("user_id")
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("idx_refresh_tokens_expires_at")
25
+ .ifNotExists()
26
+ .on("refresh_tokens")
27
+ .column("expires_at")
28
+ .execute();
29
+ }
30
+
31
+ export async function down(db: Kysely<any>): Promise<void> {
32
+ await db.schema.dropTable("refresh_tokens").ifExists().execute();
33
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+
3
+ // =============================================================================
4
+ // INPUT SCHEMAS
5
+ // =============================================================================
6
+
7
+ export const registerSchema = z.object({
8
+ email: z.string().email("Invalid email address"),
9
+ password: z.string().min(8, "Password must be at least 8 characters"),
10
+ name: z.string().min(1).optional(),
11
+ });
12
+
13
+ export const loginSchema = z.object({
14
+ email: z.string().email("Invalid email address"),
15
+ password: z.string().min(1, "Password is required"),
16
+ });
17
+
18
+ export const refreshSchema = z.object({
19
+ refreshToken: z.string(),
20
+ });
21
+
22
+ export const updateProfileSchema = z.object({
23
+ name: z.string().min(1).optional(),
24
+ email: z.string().email().optional(),
25
+ });
26
+
27
+ // =============================================================================
28
+ // OUTPUT SCHEMAS
29
+ // =============================================================================
30
+
31
+ export const userSchema = z.object({
32
+ id: z.string(),
33
+ email: z.string(),
34
+ name: z.string().nullable(),
35
+ });
36
+
37
+ export const tokensSchema = z.object({
38
+ accessToken: z.string(),
39
+ refreshToken: z.string().optional(),
40
+ expiresIn: z.number(),
41
+ });
42
+
43
+ export const authResponseSchema = z.object({
44
+ user: userSchema,
45
+ tokens: tokensSchema,
46
+ });
47
+
48
+ export const refreshResponseSchema = tokensSchema;
49
+
50
+ export const meResponseSchema = userSchema.nullable();
51
+
52
+ export const logoutResponseSchema = z.object({
53
+ success: z.boolean(),
54
+ });
55
+
56
+ // =============================================================================
57
+ // DERIVED TYPES
58
+ // =============================================================================
59
+
60
+ export type RegisterInput = z.infer<typeof registerSchema>;
61
+ export type LoginInput = z.infer<typeof loginSchema>;
62
+ export type RefreshInput = z.infer<typeof refreshSchema>;
63
+ export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
64
+ export type User = z.infer<typeof userSchema>;
65
+ export type Tokens = z.infer<typeof tokensSchema>;
66
+ export type AuthResponse = z.infer<typeof authResponseSchema>;
@@ -0,0 +1,20 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Login Handler - Authenticate user and create session/token
5
+ */
6
+ export class LoginHandler implements Handler<Routes.Auth.Login> {
7
+ constructor(private ctx: AppContext) {}
8
+
9
+ async handle(input: Routes.Auth.Login.Input): Promise<Routes.Auth.Login.Output> {
10
+ const result = await this.ctx.plugins.auth.login({
11
+ email: input.email,
12
+ password: input.password,
13
+ });
14
+
15
+ return {
16
+ user: result.user,
17
+ tokens: result.tokens,
18
+ };
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Logout Handler - Invalidate current session/token
5
+ */
6
+ export class LogoutHandler implements Handler<Routes.Auth.Logout> {
7
+ constructor(private ctx: AppContext) {}
8
+
9
+ async handle(_input: Routes.Auth.Logout.Input): Promise<Routes.Auth.Logout.Output> {
10
+ // Get token from request context (set by auth middleware)
11
+ const token = (this.ctx as any).token;
12
+
13
+ if (token) {
14
+ await this.ctx.plugins.auth.logout(token);
15
+ }
16
+
17
+ return { success: true };
18
+ }
19
+ }
@@ -0,0 +1,23 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Me Handler - Get current authenticated user
5
+ */
6
+ export class MeHandler implements Handler<Routes.Auth.Me> {
7
+ constructor(private ctx: AppContext) {}
8
+
9
+ async handle(_input: Routes.Auth.Me.Input): Promise<Routes.Auth.Me.Output> {
10
+ // Get user from request context (set by auth middleware)
11
+ const user = (this.ctx as any).user;
12
+
13
+ if (!user) {
14
+ return null;
15
+ }
16
+
17
+ return {
18
+ id: user.id,
19
+ email: user.email,
20
+ name: user.name,
21
+ };
22
+ }
23
+ }
@@ -0,0 +1,19 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Refresh Handler - Get new access token using refresh token
5
+ * Only available with refresh-token strategy
6
+ */
7
+ export class RefreshHandler implements Handler<Routes.Auth.Refresh> {
8
+ constructor(private ctx: AppContext) {}
9
+
10
+ async handle(input: Routes.Auth.Refresh.Input): Promise<Routes.Auth.Refresh.Output> {
11
+ const tokens = await this.ctx.plugins.auth.refresh(input.refreshToken);
12
+
13
+ return {
14
+ accessToken: tokens.accessToken,
15
+ refreshToken: tokens.refreshToken,
16
+ expiresIn: tokens.expiresIn,
17
+ };
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Register Handler - Create a new user account
5
+ */
6
+ export class RegisterHandler implements Handler<Routes.Auth.Register> {
7
+ constructor(private ctx: AppContext) {}
8
+
9
+ async handle(input: Routes.Auth.Register.Input): Promise<Routes.Auth.Register.Output> {
10
+ const result = await this.ctx.plugins.auth.register({
11
+ email: input.email,
12
+ password: input.password,
13
+ name: input.name,
14
+ });
15
+
16
+ return {
17
+ user: result.user,
18
+ tokens: result.tokens,
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,24 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Update Profile Handler - Update current user's profile
5
+ */
6
+ export class UpdateProfileHandler implements Handler<Routes.Auth.UpdateProfile> {
7
+ constructor(private ctx: AppContext) {}
8
+
9
+ async handle(input: Routes.Auth.UpdateProfile.Input): Promise<Routes.Auth.UpdateProfile.Output> {
10
+ // Get user from request context (set by auth middleware)
11
+ const user = (this.ctx as any).user;
12
+
13
+ if (!user) {
14
+ throw this.ctx.errors.Unauthorized("Not authenticated");
15
+ }
16
+
17
+ const updated = await this.ctx.plugins.auth.updateProfile(user.id, {
18
+ name: input.name,
19
+ email: input.email,
20
+ });
21
+
22
+ return updated;
23
+ }
24
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Auth Router - Authentication endpoints
3
+ *
4
+ * Provides:
5
+ * - auth.register - Create new account
6
+ * - auth.login - Login and get tokens
7
+ * - auth.refresh - Refresh access token (refresh-token strategy only)
8
+ * - auth.logout - Invalidate session/token (requires auth)
9
+ * - auth.me - Get current user (optional auth)
10
+ * - auth.updateProfile - Update profile (requires auth)
11
+ */
12
+
13
+ import { createRouter } from "@donkeylabs/server";
14
+ import { z } from "zod";
15
+ import {
16
+ registerSchema,
17
+ loginSchema,
18
+ refreshSchema,
19
+ updateProfileSchema,
20
+ authResponseSchema,
21
+ refreshResponseSchema,
22
+ userSchema,
23
+ logoutResponseSchema,
24
+ } from "./auth.schemas";
25
+ import { RegisterHandler } from "./handlers/register.handler";
26
+ import { LoginHandler } from "./handlers/login.handler";
27
+ import { RefreshHandler } from "./handlers/refresh.handler";
28
+ import { LogoutHandler } from "./handlers/logout.handler";
29
+ import { MeHandler } from "./handlers/me.handler";
30
+ import { UpdateProfileHandler } from "./handlers/update-profile.handler";
31
+
32
+ export const authRouter = createRouter("auth")
33
+
34
+ // Public routes
35
+ .route("register").typed({
36
+ input: registerSchema,
37
+ output: authResponseSchema,
38
+ handle: RegisterHandler,
39
+ })
40
+
41
+ .route("login").typed({
42
+ input: loginSchema,
43
+ output: authResponseSchema,
44
+ handle: LoginHandler,
45
+ })
46
+
47
+ // Refresh token (for refresh-token strategy)
48
+ .route("refresh").typed({
49
+ input: refreshSchema,
50
+ output: refreshResponseSchema,
51
+ handle: RefreshHandler,
52
+ })
53
+
54
+ // Optional auth - returns user if logged in, null otherwise
55
+ .middleware.auth({ required: false })
56
+ .route("me").typed({
57
+ input: z.object({}),
58
+ output: userSchema.nullable(),
59
+ handle: MeHandler,
60
+ })
61
+
62
+ // Protected routes - require authentication
63
+ .middleware.auth({ required: true })
64
+ .route("logout").typed({
65
+ input: z.object({}),
66
+ output: logoutResponseSchema,
67
+ handle: LogoutHandler,
68
+ })
69
+
70
+ .route("updateProfile").typed({
71
+ input: updateProfileSchema,
72
+ output: userSchema,
73
+ handle: UpdateProfileHandler,
74
+ });