@donkeylabs/cli 2.0.15 → 2.0.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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/commands/config.ts +610 -0
  3. package/src/commands/deploy-enhanced.ts +354 -0
  4. package/src/commands/deploy.ts +204 -0
  5. package/src/commands/init-enhanced.ts +1994 -0
  6. package/src/deployment/manager.ts +356 -0
  7. package/src/index.ts +47 -19
  8. package/templates/starter/.env.example +0 -44
  9. package/templates/starter/.gitignore.template +0 -4
  10. package/templates/starter/donkeylabs.config.ts +0 -6
  11. package/templates/starter/package.json +0 -21
  12. package/templates/starter/src/index.ts +0 -54
  13. package/templates/starter/src/plugins/stats/index.ts +0 -105
  14. package/templates/starter/src/routes/health/handlers/ping.ts +0 -22
  15. package/templates/starter/src/routes/health/index.ts +0 -19
  16. package/templates/starter/tsconfig.json +0 -27
  17. package/templates/sveltekit-app/.env.example +0 -59
  18. package/templates/sveltekit-app/README.md +0 -103
  19. package/templates/sveltekit-app/bun.lock +0 -683
  20. package/templates/sveltekit-app/donkeylabs.config.ts +0 -12
  21. package/templates/sveltekit-app/package.json +0 -38
  22. package/templates/sveltekit-app/src/app.css +0 -40
  23. package/templates/sveltekit-app/src/app.html +0 -12
  24. package/templates/sveltekit-app/src/hooks.server.ts +0 -4
  25. package/templates/sveltekit-app/src/lib/components/ui/badge/badge.svelte +0 -30
  26. package/templates/sveltekit-app/src/lib/components/ui/badge/index.ts +0 -3
  27. package/templates/sveltekit-app/src/lib/components/ui/button/button.svelte +0 -48
  28. package/templates/sveltekit-app/src/lib/components/ui/button/index.ts +0 -9
  29. package/templates/sveltekit-app/src/lib/components/ui/card/card-content.svelte +0 -18
  30. package/templates/sveltekit-app/src/lib/components/ui/card/card-description.svelte +0 -18
  31. package/templates/sveltekit-app/src/lib/components/ui/card/card-footer.svelte +0 -18
  32. package/templates/sveltekit-app/src/lib/components/ui/card/card-header.svelte +0 -18
  33. package/templates/sveltekit-app/src/lib/components/ui/card/card-title.svelte +0 -18
  34. package/templates/sveltekit-app/src/lib/components/ui/card/card.svelte +0 -21
  35. package/templates/sveltekit-app/src/lib/components/ui/card/index.ts +0 -21
  36. package/templates/sveltekit-app/src/lib/components/ui/index.ts +0 -4
  37. package/templates/sveltekit-app/src/lib/components/ui/input/index.ts +0 -2
  38. package/templates/sveltekit-app/src/lib/components/ui/input/input.svelte +0 -20
  39. package/templates/sveltekit-app/src/lib/permissions.ts +0 -213
  40. package/templates/sveltekit-app/src/lib/utils/index.ts +0 -6
  41. package/templates/sveltekit-app/src/routes/+layout.svelte +0 -8
  42. package/templates/sveltekit-app/src/routes/+page.server.ts +0 -25
  43. package/templates/sveltekit-app/src/routes/+page.svelte +0 -680
  44. package/templates/sveltekit-app/src/routes/workflows/+page.server.ts +0 -23
  45. package/templates/sveltekit-app/src/routes/workflows/+page.svelte +0 -522
  46. package/templates/sveltekit-app/src/server/events.ts +0 -28
  47. package/templates/sveltekit-app/src/server/index.ts +0 -124
  48. package/templates/sveltekit-app/src/server/plugins/auth/auth.test.ts +0 -377
  49. package/templates/sveltekit-app/src/server/plugins/auth/index.ts +0 -815
  50. package/templates/sveltekit-app/src/server/plugins/auth/migrations/001_create_users.ts +0 -25
  51. package/templates/sveltekit-app/src/server/plugins/auth/migrations/002_create_sessions.ts +0 -32
  52. package/templates/sveltekit-app/src/server/plugins/auth/migrations/003_create_refresh_tokens.ts +0 -33
  53. package/templates/sveltekit-app/src/server/plugins/auth/migrations/004_create_passkeys.ts +0 -60
  54. package/templates/sveltekit-app/src/server/plugins/auth/schema.ts +0 -65
  55. package/templates/sveltekit-app/src/server/plugins/demo/index.ts +0 -262
  56. package/templates/sveltekit-app/src/server/plugins/email/email.test.ts +0 -369
  57. package/templates/sveltekit-app/src/server/plugins/email/index.ts +0 -411
  58. package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts +0 -33
  59. package/templates/sveltekit-app/src/server/plugins/email/schema.ts +0 -24
  60. package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +0 -1048
  61. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts +0 -63
  62. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/002_create_roles.ts +0 -90
  63. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/003_create_resource_grants.ts +0 -50
  64. package/templates/sveltekit-app/src/server/plugins/permissions/permissions.test.ts +0 -566
  65. package/templates/sveltekit-app/src/server/plugins/permissions/schema.ts +0 -67
  66. package/templates/sveltekit-app/src/server/plugins/workflow-demo/index.ts +0 -198
  67. package/templates/sveltekit-app/src/server/routes/auth/auth.schemas.ts +0 -66
  68. package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +0 -18
  69. package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +0 -16
  70. package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +0 -20
  71. package/templates/sveltekit-app/src/server/routes/auth/handlers/refresh.handler.ts +0 -17
  72. package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +0 -19
  73. package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +0 -21
  74. package/templates/sveltekit-app/src/server/routes/auth/index.ts +0 -73
  75. package/templates/sveltekit-app/src/server/routes/demo.ts +0 -464
  76. package/templates/sveltekit-app/src/server/routes/example/example.schemas.ts +0 -22
  77. package/templates/sveltekit-app/src/server/routes/example/handlers/greet.handler.ts +0 -21
  78. package/templates/sveltekit-app/src/server/routes/example/index.ts +0 -28
  79. package/templates/sveltekit-app/src/server/routes/permissions/index.ts +0 -248
  80. package/templates/sveltekit-app/src/server/routes/tenants/index.ts +0 -339
  81. package/templates/sveltekit-app/static/robots.txt +0 -3
  82. package/templates/sveltekit-app/svelte.config.ts +0 -17
  83. package/templates/sveltekit-app/tsconfig.json +0 -20
  84. package/templates/sveltekit-app/vite.config.ts +0 -12
@@ -1,815 +0,0 @@
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.core.errors.BadRequest();
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.core.errors.Unauthorized();
531
- }
532
-
533
- const valid = await Bun.password.verify(password, dbUser.password_hash);
534
- if (!valid) {
535
- throw ctx.core.errors.Unauthorized();
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.core.errors.Unauthorized();
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.core.errors.Unauthorized();
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.core.errors.Unauthorized();
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.core.errors.BadRequest();
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
- });