@donkeylabs/cli 1.1.13 → 1.1.14
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 +1 -1
- package/templates/sveltekit-app/src/server/index.ts +4 -0
- package/templates/sveltekit-app/src/server/plugins/auth/index.ts +396 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/001_create_users.ts +25 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/002_create_sessions.ts +32 -0
- package/templates/sveltekit-app/src/server/routes/auth/auth.schemas.ts +52 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +20 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +19 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +23 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +21 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +24 -0
- package/templates/sveltekit-app/src/server/routes/auth/index.ts +63 -0
package/package.json
CHANGED
|
@@ -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<{}>({
|
|
@@ -23,10 +25,12 @@ export const server = new AppServer({
|
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
// Register plugins
|
|
28
|
+
server.registerPlugin(authPlugin); // Auth first - other plugins may depend on it
|
|
26
29
|
server.registerPlugin(demoPlugin);
|
|
27
30
|
server.registerPlugin(workflowDemoPlugin);
|
|
28
31
|
|
|
29
32
|
// Register routes
|
|
33
|
+
server.use(authRouter);
|
|
30
34
|
server.use(demoRoutes);
|
|
31
35
|
server.use(exampleRouter);
|
|
32
36
|
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Plugin - User authentication with sessions
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - User registration and login
|
|
6
|
+
* - Password hashing with bcrypt
|
|
7
|
+
* - Session-based authentication
|
|
8
|
+
* - Auth middleware for protected routes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createPlugin, createMiddleware } from "@donkeylabs/server";
|
|
12
|
+
import type { ColumnType } from "kysely";
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// DATABASE SCHEMA TYPES
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
interface UsersTable {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
password_hash: string;
|
|
22
|
+
name: string | null;
|
|
23
|
+
created_at: ColumnType<string, string | undefined, never>;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SessionsTable {
|
|
28
|
+
id: string;
|
|
29
|
+
user_id: string;
|
|
30
|
+
expires_at: string;
|
|
31
|
+
created_at: ColumnType<string, string | undefined, never>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AuthSchema {
|
|
35
|
+
users: UsersTable;
|
|
36
|
+
sessions: SessionsTable;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// TYPES
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export interface AuthUser {
|
|
44
|
+
id: string;
|
|
45
|
+
email: string;
|
|
46
|
+
name: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Session {
|
|
50
|
+
id: string;
|
|
51
|
+
userId: string;
|
|
52
|
+
expiresAt: Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// PLUGIN DEFINITION
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
export const authPlugin = createPlugin
|
|
60
|
+
.withSchema<AuthSchema>()
|
|
61
|
+
.define({
|
|
62
|
+
name: "auth",
|
|
63
|
+
|
|
64
|
+
// Custom errors for auth failures
|
|
65
|
+
customErrors: {
|
|
66
|
+
InvalidCredentials: {
|
|
67
|
+
status: 401,
|
|
68
|
+
code: "INVALID_CREDENTIALS",
|
|
69
|
+
message: "Invalid email or password",
|
|
70
|
+
},
|
|
71
|
+
EmailAlreadyExists: {
|
|
72
|
+
status: 409,
|
|
73
|
+
code: "EMAIL_EXISTS",
|
|
74
|
+
message: "An account with this email already exists",
|
|
75
|
+
},
|
|
76
|
+
SessionExpired: {
|
|
77
|
+
status: 401,
|
|
78
|
+
code: "SESSION_EXPIRED",
|
|
79
|
+
message: "Your session has expired. Please log in again.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
service: async (ctx) => {
|
|
84
|
+
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
/**
|
|
88
|
+
* Register a new user
|
|
89
|
+
*/
|
|
90
|
+
register: async (data: {
|
|
91
|
+
email: string;
|
|
92
|
+
password: string;
|
|
93
|
+
name?: string;
|
|
94
|
+
}): Promise<{ user: AuthUser; sessionId: string }> => {
|
|
95
|
+
const { email, password, name } = data;
|
|
96
|
+
|
|
97
|
+
// Check if email already exists
|
|
98
|
+
const existing = await ctx.db
|
|
99
|
+
.selectFrom("users")
|
|
100
|
+
.where("email", "=", email.toLowerCase())
|
|
101
|
+
.selectAll()
|
|
102
|
+
.executeTakeFirst();
|
|
103
|
+
|
|
104
|
+
if (existing) {
|
|
105
|
+
throw ctx.errors.EmailAlreadyExists();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Hash password
|
|
109
|
+
const passwordHash = await Bun.password.hash(password, {
|
|
110
|
+
algorithm: "bcrypt",
|
|
111
|
+
cost: 10,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const userId = crypto.randomUUID();
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
|
|
117
|
+
// Create user
|
|
118
|
+
await ctx.db
|
|
119
|
+
.insertInto("users")
|
|
120
|
+
.values({
|
|
121
|
+
id: userId,
|
|
122
|
+
email: email.toLowerCase(),
|
|
123
|
+
password_hash: passwordHash,
|
|
124
|
+
name: name || null,
|
|
125
|
+
created_at: now,
|
|
126
|
+
updated_at: now,
|
|
127
|
+
})
|
|
128
|
+
.execute();
|
|
129
|
+
|
|
130
|
+
// Create session
|
|
131
|
+
const sessionId = crypto.randomUUID();
|
|
132
|
+
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
|
133
|
+
|
|
134
|
+
await ctx.db
|
|
135
|
+
.insertInto("sessions")
|
|
136
|
+
.values({
|
|
137
|
+
id: sessionId,
|
|
138
|
+
user_id: userId,
|
|
139
|
+
expires_at: expiresAt.toISOString(),
|
|
140
|
+
created_at: now,
|
|
141
|
+
})
|
|
142
|
+
.execute();
|
|
143
|
+
|
|
144
|
+
ctx.core.logger.info("User registered", { userId, email });
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
user: { id: userId, email: email.toLowerCase(), name: name || null },
|
|
148
|
+
sessionId,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Login with email and password
|
|
154
|
+
*/
|
|
155
|
+
login: async (data: {
|
|
156
|
+
email: string;
|
|
157
|
+
password: string;
|
|
158
|
+
}): Promise<{ user: AuthUser; sessionId: string }> => {
|
|
159
|
+
const { email, password } = data;
|
|
160
|
+
|
|
161
|
+
// Find user
|
|
162
|
+
const user = await ctx.db
|
|
163
|
+
.selectFrom("users")
|
|
164
|
+
.where("email", "=", email.toLowerCase())
|
|
165
|
+
.selectAll()
|
|
166
|
+
.executeTakeFirst();
|
|
167
|
+
|
|
168
|
+
if (!user) {
|
|
169
|
+
throw ctx.errors.InvalidCredentials();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Verify password
|
|
173
|
+
const valid = await Bun.password.verify(password, user.password_hash);
|
|
174
|
+
if (!valid) {
|
|
175
|
+
throw ctx.errors.InvalidCredentials();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Create session
|
|
179
|
+
const sessionId = crypto.randomUUID();
|
|
180
|
+
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
|
181
|
+
|
|
182
|
+
await ctx.db
|
|
183
|
+
.insertInto("sessions")
|
|
184
|
+
.values({
|
|
185
|
+
id: sessionId,
|
|
186
|
+
user_id: user.id,
|
|
187
|
+
expires_at: expiresAt.toISOString(),
|
|
188
|
+
created_at: new Date().toISOString(),
|
|
189
|
+
})
|
|
190
|
+
.execute();
|
|
191
|
+
|
|
192
|
+
ctx.core.logger.info("User logged in", { userId: user.id });
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
user: { id: user.id, email: user.email, name: user.name },
|
|
196
|
+
sessionId,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Logout - invalidate session
|
|
202
|
+
*/
|
|
203
|
+
logout: async (sessionId: string): Promise<void> => {
|
|
204
|
+
await ctx.db
|
|
205
|
+
.deleteFrom("sessions")
|
|
206
|
+
.where("id", "=", sessionId)
|
|
207
|
+
.execute();
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Validate session and get user
|
|
212
|
+
*/
|
|
213
|
+
validateSession: async (sessionId: string): Promise<AuthUser | null> => {
|
|
214
|
+
const session = await ctx.db
|
|
215
|
+
.selectFrom("sessions")
|
|
216
|
+
.where("id", "=", sessionId)
|
|
217
|
+
.selectAll()
|
|
218
|
+
.executeTakeFirst();
|
|
219
|
+
|
|
220
|
+
if (!session) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check if expired
|
|
225
|
+
if (new Date(session.expires_at) < new Date()) {
|
|
226
|
+
// Clean up expired session
|
|
227
|
+
await ctx.db
|
|
228
|
+
.deleteFrom("sessions")
|
|
229
|
+
.where("id", "=", sessionId)
|
|
230
|
+
.execute();
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Get user
|
|
235
|
+
const user = await ctx.db
|
|
236
|
+
.selectFrom("users")
|
|
237
|
+
.where("id", "=", session.user_id)
|
|
238
|
+
.selectAll()
|
|
239
|
+
.executeTakeFirst();
|
|
240
|
+
|
|
241
|
+
if (!user) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
id: user.id,
|
|
247
|
+
email: user.email,
|
|
248
|
+
name: user.name,
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get user by ID
|
|
254
|
+
*/
|
|
255
|
+
getUserById: async (userId: string): Promise<AuthUser | null> => {
|
|
256
|
+
const user = await ctx.db
|
|
257
|
+
.selectFrom("users")
|
|
258
|
+
.where("id", "=", userId)
|
|
259
|
+
.selectAll()
|
|
260
|
+
.executeTakeFirst();
|
|
261
|
+
|
|
262
|
+
if (!user) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id: user.id,
|
|
268
|
+
email: user.email,
|
|
269
|
+
name: user.name,
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Update user profile
|
|
275
|
+
*/
|
|
276
|
+
updateProfile: async (
|
|
277
|
+
userId: string,
|
|
278
|
+
data: { name?: string; email?: string }
|
|
279
|
+
): Promise<AuthUser> => {
|
|
280
|
+
const updates: Record<string, string> = {
|
|
281
|
+
updated_at: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (data.name !== undefined) {
|
|
285
|
+
updates.name = data.name;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (data.email !== undefined) {
|
|
289
|
+
// Check if email is taken by another user
|
|
290
|
+
const existing = await ctx.db
|
|
291
|
+
.selectFrom("users")
|
|
292
|
+
.where("email", "=", data.email.toLowerCase())
|
|
293
|
+
.where("id", "!=", userId)
|
|
294
|
+
.selectAll()
|
|
295
|
+
.executeTakeFirst();
|
|
296
|
+
|
|
297
|
+
if (existing) {
|
|
298
|
+
throw ctx.errors.EmailAlreadyExists();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
updates.email = data.email.toLowerCase();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await ctx.db
|
|
305
|
+
.updateTable("users")
|
|
306
|
+
.set(updates)
|
|
307
|
+
.where("id", "=", userId)
|
|
308
|
+
.execute();
|
|
309
|
+
|
|
310
|
+
const user = await ctx.db
|
|
311
|
+
.selectFrom("users")
|
|
312
|
+
.where("id", "=", userId)
|
|
313
|
+
.selectAll()
|
|
314
|
+
.executeTakeFirstOrThrow();
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
id: user.id,
|
|
318
|
+
email: user.email,
|
|
319
|
+
name: user.name,
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Delete all expired sessions (cleanup job)
|
|
325
|
+
*/
|
|
326
|
+
cleanupExpiredSessions: async (): Promise<number> => {
|
|
327
|
+
const result = await ctx.db
|
|
328
|
+
.deleteFrom("sessions")
|
|
329
|
+
.where("expires_at", "<", new Date().toISOString())
|
|
330
|
+
.executeTakeFirst();
|
|
331
|
+
|
|
332
|
+
const count = Number(result.numDeletedRows);
|
|
333
|
+
if (count > 0) {
|
|
334
|
+
ctx.core.logger.info("Cleaned up expired sessions", { count });
|
|
335
|
+
}
|
|
336
|
+
return count;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Auth middleware - validates session from cookie/header
|
|
343
|
+
*/
|
|
344
|
+
middleware: (ctx, service) => ({
|
|
345
|
+
/**
|
|
346
|
+
* Require authentication middleware
|
|
347
|
+
* Sets ctx.user if valid session found
|
|
348
|
+
* Returns 401 if required and no valid session
|
|
349
|
+
*/
|
|
350
|
+
auth: createMiddleware<{ required?: boolean }>(
|
|
351
|
+
async (req, reqCtx, next, config) => {
|
|
352
|
+
// Get session ID from cookie or Authorization header
|
|
353
|
+
const cookies = req.headers.get("cookie") || "";
|
|
354
|
+
const cookieMatch = cookies.match(/session=([^;]+)/);
|
|
355
|
+
const headerToken = req.headers.get("authorization")?.replace("Bearer ", "");
|
|
356
|
+
|
|
357
|
+
const sessionId = cookieMatch?.[1] || headerToken;
|
|
358
|
+
|
|
359
|
+
if (sessionId) {
|
|
360
|
+
const user = await service.validateSession(sessionId);
|
|
361
|
+
if (user) {
|
|
362
|
+
// Set user on request context
|
|
363
|
+
(reqCtx as any).user = user;
|
|
364
|
+
(reqCtx as any).sessionId = sessionId;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// If auth is required and no user, return 401
|
|
369
|
+
if (config?.required && !(reqCtx as any).user) {
|
|
370
|
+
return Response.json(
|
|
371
|
+
{ error: "Unauthorized", code: "UNAUTHORIZED" },
|
|
372
|
+
{ status: 401 }
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return next();
|
|
377
|
+
}
|
|
378
|
+
),
|
|
379
|
+
}),
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Initialize cleanup cron job
|
|
383
|
+
*/
|
|
384
|
+
init: async (ctx, service) => {
|
|
385
|
+
// Clean up expired sessions daily at 3am
|
|
386
|
+
ctx.core.cron.schedule(
|
|
387
|
+
"0 3 * * *",
|
|
388
|
+
async () => {
|
|
389
|
+
await service.cleanupExpiredSessions();
|
|
390
|
+
},
|
|
391
|
+
{ name: "auth-session-cleanup" }
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
ctx.core.logger.info("Auth plugin initialized");
|
|
395
|
+
},
|
|
396
|
+
});
|
|
@@ -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,52 @@
|
|
|
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 updateProfileSchema = z.object({
|
|
19
|
+
name: z.string().min(1).optional(),
|
|
20
|
+
email: z.string().email().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// OUTPUT SCHEMAS
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
export const userSchema = z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
email: z.string(),
|
|
30
|
+
name: z.string().nullable(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const authResponseSchema = z.object({
|
|
34
|
+
user: userSchema,
|
|
35
|
+
sessionId: z.string(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const meResponseSchema = userSchema.nullable();
|
|
39
|
+
|
|
40
|
+
export const logoutResponseSchema = z.object({
|
|
41
|
+
success: z.boolean(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// DERIVED TYPES
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
export type RegisterInput = z.infer<typeof registerSchema>;
|
|
49
|
+
export type LoginInput = z.infer<typeof loginSchema>;
|
|
50
|
+
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
|
51
|
+
export type User = z.infer<typeof userSchema>;
|
|
52
|
+
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
|
|
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
|
+
sessionId: result.sessionId,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logout Handler - Invalidate current session
|
|
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 session ID from request context (set by auth middleware)
|
|
11
|
+
const sessionId = (this.ctx as any).sessionId;
|
|
12
|
+
|
|
13
|
+
if (sessionId) {
|
|
14
|
+
await this.ctx.plugins.auth.logout(sessionId);
|
|
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,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
|
+
sessionId: result.sessionId,
|
|
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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Router - Authentication endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - auth.register - Create new account
|
|
6
|
+
* - auth.login - Login and get session
|
|
7
|
+
* - auth.logout - Invalidate session (requires auth)
|
|
8
|
+
* - auth.me - Get current user (optional auth)
|
|
9
|
+
* - auth.updateProfile - Update profile (requires auth)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRouter } from "@donkeylabs/server";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import {
|
|
15
|
+
registerSchema,
|
|
16
|
+
loginSchema,
|
|
17
|
+
updateProfileSchema,
|
|
18
|
+
authResponseSchema,
|
|
19
|
+
userSchema,
|
|
20
|
+
logoutResponseSchema,
|
|
21
|
+
} from "./auth.schemas";
|
|
22
|
+
import { RegisterHandler } from "./handlers/register.handler";
|
|
23
|
+
import { LoginHandler } from "./handlers/login.handler";
|
|
24
|
+
import { LogoutHandler } from "./handlers/logout.handler";
|
|
25
|
+
import { MeHandler } from "./handlers/me.handler";
|
|
26
|
+
import { UpdateProfileHandler } from "./handlers/update-profile.handler";
|
|
27
|
+
|
|
28
|
+
export const authRouter = createRouter("auth")
|
|
29
|
+
|
|
30
|
+
// Public routes
|
|
31
|
+
.route("register").typed({
|
|
32
|
+
input: registerSchema,
|
|
33
|
+
output: authResponseSchema,
|
|
34
|
+
handle: RegisterHandler,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
.route("login").typed({
|
|
38
|
+
input: loginSchema,
|
|
39
|
+
output: authResponseSchema,
|
|
40
|
+
handle: LoginHandler,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Optional auth - returns user if logged in, null otherwise
|
|
44
|
+
.middleware.auth({ required: false })
|
|
45
|
+
.route("me").typed({
|
|
46
|
+
input: z.object({}),
|
|
47
|
+
output: userSchema.nullable(),
|
|
48
|
+
handle: MeHandler,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Protected routes - require authentication
|
|
52
|
+
.middleware.auth({ required: true })
|
|
53
|
+
.route("logout").typed({
|
|
54
|
+
input: z.object({}),
|
|
55
|
+
output: logoutResponseSchema,
|
|
56
|
+
handle: LogoutHandler,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
.route("updateProfile").typed({
|
|
60
|
+
input: updateProfileSchema,
|
|
61
|
+
output: userSchema,
|
|
62
|
+
handle: UpdateProfileHandler,
|
|
63
|
+
});
|