create-blitzpack 0.1.0

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 (259) hide show
  1. package/dist/index.js +452 -0
  2. package/package.json +57 -0
  3. package/template/.dockerignore +59 -0
  4. package/template/.github/workflows/ci.yml +157 -0
  5. package/template/.husky/pre-commit +1 -0
  6. package/template/.husky/pre-push +1 -0
  7. package/template/.lintstagedrc.cjs +4 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/.prettierrc +9 -0
  10. package/template/.vscode/settings.json +13 -0
  11. package/template/CLAUDE.md +175 -0
  12. package/template/CONTRIBUTING.md +32 -0
  13. package/template/Dockerfile +90 -0
  14. package/template/GETTING_STARTED.md +35 -0
  15. package/template/LICENSE +21 -0
  16. package/template/README.md +116 -0
  17. package/template/apps/api/.dockerignore +51 -0
  18. package/template/apps/api/.env.local.example +62 -0
  19. package/template/apps/api/emails/account-deleted-email.tsx +69 -0
  20. package/template/apps/api/emails/components/email-layout.tsx +154 -0
  21. package/template/apps/api/emails/config.ts +22 -0
  22. package/template/apps/api/emails/password-changed-email.tsx +88 -0
  23. package/template/apps/api/emails/password-reset-email.tsx +86 -0
  24. package/template/apps/api/emails/verification-email.tsx +85 -0
  25. package/template/apps/api/emails/welcome-email.tsx +70 -0
  26. package/template/apps/api/package.json +84 -0
  27. package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
  28. package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
  29. package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
  30. package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
  31. package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
  32. package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
  33. package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
  34. package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
  35. package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
  36. package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
  37. package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +116 -0
  40. package/template/apps/api/prisma/seed.ts +159 -0
  41. package/template/apps/api/prisma.config.ts +14 -0
  42. package/template/apps/api/src/app.ts +377 -0
  43. package/template/apps/api/src/common/logger.service.ts +227 -0
  44. package/template/apps/api/src/config/env.ts +60 -0
  45. package/template/apps/api/src/config/rate-limit.ts +29 -0
  46. package/template/apps/api/src/hooks/auth.ts +122 -0
  47. package/template/apps/api/src/plugins/auth.ts +198 -0
  48. package/template/apps/api/src/plugins/database.ts +45 -0
  49. package/template/apps/api/src/plugins/logger.ts +33 -0
  50. package/template/apps/api/src/plugins/multipart.ts +16 -0
  51. package/template/apps/api/src/plugins/scalar.ts +20 -0
  52. package/template/apps/api/src/plugins/schedule.ts +52 -0
  53. package/template/apps/api/src/plugins/services.ts +66 -0
  54. package/template/apps/api/src/plugins/swagger.ts +56 -0
  55. package/template/apps/api/src/routes/accounts.ts +91 -0
  56. package/template/apps/api/src/routes/admin-sessions.ts +92 -0
  57. package/template/apps/api/src/routes/metrics.ts +71 -0
  58. package/template/apps/api/src/routes/password.ts +46 -0
  59. package/template/apps/api/src/routes/sessions.ts +53 -0
  60. package/template/apps/api/src/routes/stats.ts +38 -0
  61. package/template/apps/api/src/routes/uploads-serve.ts +27 -0
  62. package/template/apps/api/src/routes/uploads.ts +154 -0
  63. package/template/apps/api/src/routes/users.ts +114 -0
  64. package/template/apps/api/src/routes/verification.ts +90 -0
  65. package/template/apps/api/src/server.ts +34 -0
  66. package/template/apps/api/src/services/accounts.service.ts +125 -0
  67. package/template/apps/api/src/services/authorization.service.ts +162 -0
  68. package/template/apps/api/src/services/email.service.ts +170 -0
  69. package/template/apps/api/src/services/file-storage.service.ts +267 -0
  70. package/template/apps/api/src/services/metrics.service.ts +175 -0
  71. package/template/apps/api/src/services/password.service.ts +56 -0
  72. package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
  73. package/template/apps/api/src/services/sessions.service.ts +276 -0
  74. package/template/apps/api/src/services/stats.service.ts +273 -0
  75. package/template/apps/api/src/services/uploads.service.ts +163 -0
  76. package/template/apps/api/src/services/users.service.spec.ts +249 -0
  77. package/template/apps/api/src/services/users.service.ts +198 -0
  78. package/template/apps/api/src/utils/file-validation.ts +108 -0
  79. package/template/apps/api/start.sh +33 -0
  80. package/template/apps/api/test/helpers/fastify-app.ts +24 -0
  81. package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
  82. package/template/apps/api/test/helpers/mock-logger.ts +28 -0
  83. package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
  84. package/template/apps/api/test/helpers/test-db.ts +125 -0
  85. package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
  86. package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
  87. package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
  88. package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
  89. package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
  90. package/template/apps/api/test/setup.ts +31 -0
  91. package/template/apps/api/tsconfig.json +26 -0
  92. package/template/apps/api/vitest.config.ts +35 -0
  93. package/template/apps/web/.env.local.example +11 -0
  94. package/template/apps/web/components.json +24 -0
  95. package/template/apps/web/next.config.ts +22 -0
  96. package/template/apps/web/package.json +56 -0
  97. package/template/apps/web/postcss.config.js +5 -0
  98. package/template/apps/web/public/apple-icon.png +0 -0
  99. package/template/apps/web/public/icon.png +0 -0
  100. package/template/apps/web/public/robots.txt +3 -0
  101. package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
  102. package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
  103. package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
  104. package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
  105. package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
  106. package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
  107. package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
  108. package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
  109. package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
  110. package/template/apps/web/src/app/dashboard/page.tsx +296 -0
  111. package/template/apps/web/src/app/error.tsx +32 -0
  112. package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
  113. package/template/apps/web/src/app/favicon.ico +0 -0
  114. package/template/apps/web/src/app/global-error.tsx +96 -0
  115. package/template/apps/web/src/app/globals.css +22 -0
  116. package/template/apps/web/src/app/icon.png +0 -0
  117. package/template/apps/web/src/app/layout.tsx +34 -0
  118. package/template/apps/web/src/app/not-found.tsx +28 -0
  119. package/template/apps/web/src/app/page.tsx +192 -0
  120. package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
  121. package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
  122. package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
  123. package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
  124. package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
  125. package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
  126. package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
  127. package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
  128. package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
  129. package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
  130. package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
  131. package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
  132. package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
  133. package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
  134. package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
  135. package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
  136. package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
  137. package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
  138. package/template/apps/web/src/components/auth/github-button.tsx +40 -0
  139. package/template/apps/web/src/components/auth/google-button.tsx +54 -0
  140. package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
  141. package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
  142. package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
  143. package/template/apps/web/src/components/error/error-card.tsx +47 -0
  144. package/template/apps/web/src/components/error/forbidden.tsx +25 -0
  145. package/template/apps/web/src/components/landing/command-block.tsx +64 -0
  146. package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
  147. package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
  148. package/template/apps/web/src/components/landing/logo.tsx +41 -0
  149. package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
  150. package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
  151. package/template/apps/web/src/components/layout/footer.tsx +3 -0
  152. package/template/apps/web/src/config/landing-data.ts +152 -0
  153. package/template/apps/web/src/config/site.ts +5 -0
  154. package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
  155. package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
  156. package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
  157. package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
  158. package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
  159. package/template/apps/web/src/hooks/api/use-users.ts +149 -0
  160. package/template/apps/web/src/hooks/use-mobile.ts +21 -0
  161. package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
  162. package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
  163. package/template/apps/web/src/lib/api.ts +151 -0
  164. package/template/apps/web/src/lib/auth.ts +13 -0
  165. package/template/apps/web/src/lib/env.ts +52 -0
  166. package/template/apps/web/src/lib/form-utils.ts +11 -0
  167. package/template/apps/web/src/lib/utils.ts +1 -0
  168. package/template/apps/web/src/providers.tsx +34 -0
  169. package/template/apps/web/src/store/atoms.ts +15 -0
  170. package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
  171. package/template/apps/web/src/test/setup.ts +8 -0
  172. package/template/apps/web/tailwind.config.ts +5 -0
  173. package/template/apps/web/tsconfig.json +26 -0
  174. package/template/apps/web/vitest.config.ts +32 -0
  175. package/template/assets/logo-512.png +0 -0
  176. package/template/assets/logo.svg +4 -0
  177. package/template/docker-compose.prod.yml +66 -0
  178. package/template/docker-compose.yml +36 -0
  179. package/template/eslint.config.ts +119 -0
  180. package/template/package.json +77 -0
  181. package/template/packages/tailwind-config/package.json +9 -0
  182. package/template/packages/tailwind-config/theme.css +179 -0
  183. package/template/packages/types/package.json +29 -0
  184. package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
  185. package/template/packages/types/src/api-response.ts +53 -0
  186. package/template/packages/types/src/health-check.ts +11 -0
  187. package/template/packages/types/src/pagination.ts +41 -0
  188. package/template/packages/types/src/role.ts +5 -0
  189. package/template/packages/types/src/session.ts +48 -0
  190. package/template/packages/types/src/stats.ts +113 -0
  191. package/template/packages/types/src/upload.ts +51 -0
  192. package/template/packages/types/src/user.ts +36 -0
  193. package/template/packages/types/tsconfig.json +5 -0
  194. package/template/packages/types/vitest.config.ts +21 -0
  195. package/template/packages/ui/components.json +21 -0
  196. package/template/packages/ui/package.json +108 -0
  197. package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
  198. package/template/packages/ui/src/alert-dialog.tsx +141 -0
  199. package/template/packages/ui/src/alert.tsx +66 -0
  200. package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
  201. package/template/packages/ui/src/avatar.tsx +53 -0
  202. package/template/packages/ui/src/badge.tsx +36 -0
  203. package/template/packages/ui/src/button.tsx +84 -0
  204. package/template/packages/ui/src/card.tsx +92 -0
  205. package/template/packages/ui/src/checkbox.tsx +32 -0
  206. package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
  207. package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
  208. package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
  209. package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
  210. package/template/packages/ui/src/data-table/data-table.tsx +167 -0
  211. package/template/packages/ui/src/dialog.tsx +143 -0
  212. package/template/packages/ui/src/dropdown-menu.tsx +257 -0
  213. package/template/packages/ui/src/empty-state.tsx +52 -0
  214. package/template/packages/ui/src/file-upload-input.tsx +202 -0
  215. package/template/packages/ui/src/form.tsx +168 -0
  216. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  217. package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
  218. package/template/packages/ui/src/input.tsx +21 -0
  219. package/template/packages/ui/src/label.tsx +24 -0
  220. package/template/packages/ui/src/lib/utils.ts +6 -0
  221. package/template/packages/ui/src/password-input.tsx +102 -0
  222. package/template/packages/ui/src/popover.tsx +48 -0
  223. package/template/packages/ui/src/radio-group.tsx +45 -0
  224. package/template/packages/ui/src/scroll-area.tsx +58 -0
  225. package/template/packages/ui/src/select.tsx +187 -0
  226. package/template/packages/ui/src/separator.tsx +28 -0
  227. package/template/packages/ui/src/sheet.tsx +139 -0
  228. package/template/packages/ui/src/sidebar.tsx +726 -0
  229. package/template/packages/ui/src/skeleton-variants.tsx +87 -0
  230. package/template/packages/ui/src/skeleton.tsx +13 -0
  231. package/template/packages/ui/src/slider.tsx +63 -0
  232. package/template/packages/ui/src/sonner.tsx +25 -0
  233. package/template/packages/ui/src/spinner.tsx +16 -0
  234. package/template/packages/ui/src/switch.tsx +31 -0
  235. package/template/packages/ui/src/table.tsx +116 -0
  236. package/template/packages/ui/src/tabs.tsx +66 -0
  237. package/template/packages/ui/src/textarea.tsx +18 -0
  238. package/template/packages/ui/src/tooltip.tsx +61 -0
  239. package/template/packages/ui/src/user-avatar.tsx +97 -0
  240. package/template/packages/ui/test-config.js +3 -0
  241. package/template/packages/ui/tsconfig.json +12 -0
  242. package/template/packages/ui/turbo.json +18 -0
  243. package/template/packages/ui/vitest.config.ts +17 -0
  244. package/template/packages/ui/vitest.setup.ts +1 -0
  245. package/template/packages/utils/package.json +23 -0
  246. package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
  247. package/template/packages/utils/src/array.ts +18 -0
  248. package/template/packages/utils/src/async.ts +3 -0
  249. package/template/packages/utils/src/date.ts +77 -0
  250. package/template/packages/utils/src/errors.ts +73 -0
  251. package/template/packages/utils/src/number.ts +11 -0
  252. package/template/packages/utils/src/string.ts +13 -0
  253. package/template/packages/utils/tsconfig.json +5 -0
  254. package/template/packages/utils/vitest.config.ts +21 -0
  255. package/template/pnpm-workspace.yaml +4 -0
  256. package/template/tsconfig.base.json +32 -0
  257. package/template/turbo.json +133 -0
  258. package/template/vitest.shared.ts +26 -0
  259. package/template/vitest.workspace.ts +9 -0
@@ -0,0 +1,122 @@
1
+ import { ForbiddenError, UnauthorizedError } from '@repo/packages-utils/errors';
2
+ import type { FastifyReply, FastifyRequest } from 'fastify';
3
+
4
+ export interface AuthUser {
5
+ id: string;
6
+ email: string;
7
+ name: string;
8
+ role: string;
9
+ session?: {
10
+ id: string;
11
+ };
12
+ }
13
+
14
+ declare module 'fastify' {
15
+ interface FastifyRequest {
16
+ user?: AuthUser;
17
+ }
18
+ }
19
+
20
+ export async function requireAuth(
21
+ request: FastifyRequest,
22
+ reply: FastifyReply
23
+ ): Promise<void> {
24
+ try {
25
+ const session = await request.server.auth.api.getSession({
26
+ headers: request.headers as unknown as Headers,
27
+ });
28
+
29
+ if (!session?.user) {
30
+ throw new UnauthorizedError('Authentication required');
31
+ }
32
+
33
+ // Fetch user from database to check ban status and get latest role
34
+ const user = await request.server.prisma.user.findUnique({
35
+ where: { id: session.user.id },
36
+ select: {
37
+ id: true,
38
+ email: true,
39
+ name: true,
40
+ role: true,
41
+ banned: true,
42
+ banReason: true,
43
+ banExpires: true,
44
+ },
45
+ });
46
+
47
+ if (!user) {
48
+ request.log.warn(
49
+ {
50
+ userId: session.user.id,
51
+ },
52
+ 'User not found in database'
53
+ );
54
+ throw new UnauthorizedError('User not found');
55
+ }
56
+
57
+ // Check if user is banned
58
+ if (user.banned) {
59
+ // Check if ban has expired
60
+ if (user.banExpires && new Date() > user.banExpires) {
61
+ // Automatically unban user
62
+ await request.server.prisma.user.update({
63
+ where: { id: user.id },
64
+ data: {
65
+ banned: false,
66
+ banReason: null,
67
+ banExpires: null,
68
+ },
69
+ });
70
+ request.log.info({ userId: user.id }, 'User ban expired and removed');
71
+ } else {
72
+ request.log.warn(
73
+ {
74
+ userId: user.id,
75
+ banReason: user.banReason,
76
+ },
77
+ 'Banned user attempted to access system'
78
+ );
79
+ throw new ForbiddenError('Your account has been banned', {
80
+ reason: user.banReason,
81
+ expiresAt: user.banExpires?.toISOString(),
82
+ });
83
+ }
84
+ }
85
+
86
+ request.user = {
87
+ id: user.id,
88
+ email: user.email,
89
+ name: user.name ?? '',
90
+ role: user.role,
91
+ session: session.session
92
+ ? {
93
+ id: session.session.id,
94
+ }
95
+ : undefined,
96
+ };
97
+ } catch (error) {
98
+ if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
99
+ throw error;
100
+ }
101
+ request.log.error(error, 'Auth hook error');
102
+ throw new UnauthorizedError('Invalid or expired session');
103
+ }
104
+ }
105
+
106
+ export function requireRole(roles: string[]) {
107
+ return async (
108
+ request: FastifyRequest,
109
+ reply: FastifyReply
110
+ ): Promise<void> => {
111
+ if (!request.user) {
112
+ throw new UnauthorizedError('Authentication required');
113
+ }
114
+
115
+ if (!roles.includes(request.user.role)) {
116
+ throw new ForbiddenError('Insufficient permissions', {
117
+ required: roles,
118
+ current: request.user.role,
119
+ });
120
+ }
121
+ };
122
+ }
@@ -0,0 +1,198 @@
1
+ import { betterAuth } from 'better-auth';
2
+ import { prismaAdapter } from 'better-auth/adapters/prisma';
3
+ import { admin } from 'better-auth/plugins';
4
+ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
5
+ import fp from 'fastify-plugin';
6
+
7
+ import { loadEnv } from '@/config/env.js';
8
+ import { RATE_LIMIT_CONFIG } from '@/config/rate-limit.js';
9
+ import { type PrismaClient } from '@/generated/client/client.js';
10
+
11
+ declare module 'fastify' {
12
+ interface FastifyInstance {
13
+ auth: ReturnType<typeof betterAuth>;
14
+ }
15
+ }
16
+
17
+ const authPlugin: FastifyPluginAsync = async (app) => {
18
+ const env = loadEnv();
19
+
20
+ const socialProviders: Record<string, unknown> = {};
21
+
22
+ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
23
+ socialProviders.google = {
24
+ clientId: env.GOOGLE_CLIENT_ID,
25
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
26
+ };
27
+ }
28
+
29
+ if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) {
30
+ socialProviders.github = {
31
+ clientId: env.GITHUB_CLIENT_ID,
32
+ clientSecret: env.GITHUB_CLIENT_SECRET,
33
+ };
34
+ }
35
+
36
+ app.log.info(
37
+ `[+] OAuth configured with providers: ${Object.keys(socialProviders).join(', ')}`
38
+ );
39
+
40
+ const auth = betterAuth({
41
+ database: prismaAdapter(app.prisma as unknown as PrismaClient, {
42
+ provider: 'postgresql',
43
+ }),
44
+ baseURL: env.BETTER_AUTH_URL,
45
+ secret: env.BETTER_AUTH_SECRET,
46
+ emailAndPassword: {
47
+ enabled: true,
48
+ requireEmailVerification: false,
49
+ sendEmailVerificationOnSignUp: true,
50
+ autoSignInAfterVerification: true,
51
+ resetPasswordTokenExpiresIn: 3600,
52
+ sendResetPassword: async ({ user, token }) => {
53
+ const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`;
54
+ await app.emailService.sendPasswordResetEmail(user.email, resetUrl);
55
+ },
56
+ },
57
+ emailVerification: {
58
+ sendOnSignUp: true,
59
+ autoSignInAfterVerification: true,
60
+ sendVerificationEmail: async ({ user, url }) => {
61
+ const urlObj = new URL(url);
62
+ urlObj.searchParams.set('callbackURL', env.FRONTEND_URL);
63
+ await app.emailService.sendVerificationEmail(
64
+ user.email,
65
+ urlObj.toString()
66
+ );
67
+ },
68
+ },
69
+ session: {
70
+ expiresIn: 60 * 60 * 24 * 7, // 7 days
71
+ updateAge: 60 * 60 * 24, // Update every 24 hours
72
+ cookieCache: {
73
+ enabled: true,
74
+ maxAge: 60 * 60 * 24 * 30, // 30 days
75
+ },
76
+ },
77
+ advanced: {
78
+ // IMPORTANT: For cross-domain deployments
79
+ // useSecureCookies forces secure cookies even in development
80
+ useSecureCookies: env.NODE_ENV === 'production',
81
+ database: {
82
+ generateId: () =>
83
+ `auth-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
84
+ },
85
+ redirectURLs: {
86
+ onError: env.FRONTEND_URL,
87
+ afterSignIn: env.FRONTEND_URL,
88
+ },
89
+ },
90
+ trustedOrigins: [env.FRONTEND_URL],
91
+ socialProviders,
92
+ plugins: [
93
+ admin({
94
+ defaultRole: 'user',
95
+ adminRoles: ['admin', 'super_admin'],
96
+ }),
97
+ ],
98
+ });
99
+
100
+ app.decorate('auth', auth);
101
+
102
+ app.all(
103
+ '/api/auth/*',
104
+ {
105
+ config: {
106
+ rateLimit: {
107
+ max: RATE_LIMIT_CONFIG.routes.auth.max,
108
+ timeWindow: RATE_LIMIT_CONFIG.routes.auth.timeWindow,
109
+ },
110
+ },
111
+ },
112
+ async (request, reply) => {
113
+ try {
114
+ // Convert Fastify request to Web Request for Better Auth
115
+ const webRequest = await toWebRequest(request);
116
+
117
+ // Handle the request with Better Auth
118
+ const response = await auth.handler(webRequest);
119
+
120
+ // Set status
121
+ reply.status(response.status);
122
+
123
+ // Process headers and modify Set-Cookie for cross-domain
124
+ response.headers.forEach((value, key) => {
125
+ if (key.toLowerCase() === 'set-cookie') {
126
+ // For cross-domain cookie support, we need to modify cookie attributes
127
+ // Better Auth sets cookies, but we need to ensure SameSite=None for cross-domain
128
+ const cookieValue = value;
129
+
130
+ if (env.NODE_ENV === 'production') {
131
+ let modifiedCookie = cookieValue;
132
+
133
+ if (!modifiedCookie.includes('Secure')) {
134
+ modifiedCookie += '; Secure';
135
+ }
136
+
137
+ if (modifiedCookie.includes('SameSite=Lax')) {
138
+ modifiedCookie = modifiedCookie.replace(
139
+ 'SameSite=Lax',
140
+ 'SameSite=None'
141
+ );
142
+ } else if (!modifiedCookie.includes('SameSite=')) {
143
+ modifiedCookie += '; SameSite=None';
144
+ }
145
+
146
+ reply.header(key, modifiedCookie);
147
+ } else {
148
+ reply.header(key, cookieValue);
149
+ }
150
+ } else {
151
+ reply.header(key, value);
152
+ }
153
+ });
154
+
155
+ const body = await response.text();
156
+ return reply.send(body);
157
+ } catch (error) {
158
+ app.log.error(error, 'Better Auth handler error');
159
+ return reply.status(500).send({
160
+ statusCode: 500,
161
+ error: 'Internal Server Error',
162
+ message: 'Authentication error occurred',
163
+ });
164
+ }
165
+ }
166
+ );
167
+
168
+ app.log.info('[+] Better Auth configured');
169
+ };
170
+
171
+ async function toWebRequest(request: FastifyRequest): Promise<Request> {
172
+ const url = new URL(request.url, `${request.protocol}://${request.hostname}`);
173
+
174
+ const headers = new Headers();
175
+ Object.entries(request.headers).forEach(([key, value]) => {
176
+ if (value) {
177
+ const headerValue = Array.isArray(value)
178
+ ? value.join(', ')
179
+ : String(value);
180
+ headers.set(key, headerValue);
181
+ }
182
+ });
183
+
184
+ let body: string | null = null;
185
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
186
+ if (request.body) {
187
+ body = JSON.stringify(request.body);
188
+ }
189
+ }
190
+
191
+ return new Request(url.toString(), {
192
+ method: request.method,
193
+ headers,
194
+ body,
195
+ });
196
+ }
197
+
198
+ export default fp(authPlugin);
@@ -0,0 +1,45 @@
1
+ import 'dotenv-flow/config';
2
+
3
+ import { PrismaPg } from '@prisma/adapter-pg';
4
+ import { type FastifyPluginAsync } from 'fastify';
5
+ import fp from 'fastify-plugin';
6
+ import { Pool } from 'pg';
7
+
8
+ import { loadEnv } from '@/config/env';
9
+ import { PrismaClient } from '@/generated/client/client.js';
10
+
11
+ declare module 'fastify' {
12
+ interface FastifyInstance {
13
+ prisma: PrismaClient;
14
+ pgPool: Pool;
15
+ }
16
+ }
17
+
18
+ const databasePlugin: FastifyPluginAsync = async (app) => {
19
+ const env = loadEnv();
20
+
21
+ const pool = new Pool({ connectionString: env.DATABASE_URL });
22
+ const adapter = new PrismaPg(pool);
23
+
24
+ const prisma = new PrismaClient({
25
+ adapter,
26
+ log:
27
+ env.LOG_LEVEL === 'verbose'
28
+ ? ['query', 'info', 'warn', 'error']
29
+ : ['warn', 'error'],
30
+ });
31
+
32
+ await prisma.$connect();
33
+ app.log.info('[+] Database connected successfully');
34
+
35
+ app.decorate('prisma', prisma);
36
+ app.decorate('pgPool', pool);
37
+
38
+ app.addHook('onClose', async (instance) => {
39
+ instance.log.info('[-] Disconnecting from database...');
40
+ await instance.prisma.$disconnect();
41
+ await instance.pgPool.end();
42
+ });
43
+ };
44
+
45
+ export default fp(databasePlugin);
@@ -0,0 +1,33 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+
4
+ import { LoggerService } from '@/common/logger.service';
5
+ import { loadEnv } from '@/config/env';
6
+
7
+ const env = loadEnv();
8
+
9
+ declare module 'fastify' {
10
+ interface FastifyInstance {
11
+ logger: LoggerService;
12
+ }
13
+ interface FastifyRequest {
14
+ logger: LoggerService;
15
+ }
16
+ }
17
+
18
+ const loggerPlugin: FastifyPluginAsync = async (app) => {
19
+ const logger = new LoggerService(app.log);
20
+ logger.setContext('FastifyApp');
21
+
22
+ app.decorate('logger', logger);
23
+
24
+ app.addHook('onRequest', async (request) => {
25
+ request.logger = new LoggerService(request.log);
26
+ });
27
+
28
+ app.log.info(
29
+ `[+] Logger service configured with verbosity: ${env.LOG_LEVEL}`
30
+ );
31
+ };
32
+
33
+ export default fp(loggerPlugin);
@@ -0,0 +1,16 @@
1
+ import multipart from '@fastify/multipart';
2
+ import type { FastifyPluginAsync } from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+
5
+ import { MAX_FILE_SIZE } from '@/utils/file-validation';
6
+
7
+ const multipartPlugin: FastifyPluginAsync = async (app) => {
8
+ await app.register(multipart, {
9
+ limits: {
10
+ fileSize: MAX_FILE_SIZE,
11
+ files: 1, // Allow 1 file per request (can be increased)
12
+ },
13
+ });
14
+ };
15
+
16
+ export default fp(multipartPlugin);
@@ -0,0 +1,20 @@
1
+ import apiReference from '@scalar/fastify-api-reference';
2
+ import type { FastifyPluginAsync } from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+
5
+ const scalarPlugin: FastifyPluginAsync = async (app) => {
6
+ await app.register(apiReference, {
7
+ routePrefix: '/docs',
8
+ configuration: {
9
+ theme: 'purple',
10
+ darkMode: true,
11
+ layout: 'modern',
12
+ showSidebar: true,
13
+ searchHotKey: 'k',
14
+ },
15
+ });
16
+
17
+ app.log.info('[+] Scalar docs available at /docs');
18
+ };
19
+
20
+ export default fp(scalarPlugin);
@@ -0,0 +1,52 @@
1
+ import fastifySchedule from '@fastify/schedule';
2
+ import type { FastifyPluginAsync } from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+ import { SimpleIntervalJob, Task } from 'toad-scheduler';
5
+
6
+ const schedulePlugin: FastifyPluginAsync = async (app) => {
7
+ // Register the schedule plugin
8
+ await app.register(fastifySchedule);
9
+
10
+ // Session cleanup task - runs every day at 3 AM (86400 seconds = 24 hours)
11
+ const sessionCleanupTask = new Task('session-cleanup', async () => {
12
+ const logger = app.logger.child('SessionCleanupTask');
13
+ logger.info('Starting expired session cleanup');
14
+
15
+ try {
16
+ const result = await app.prisma.session.deleteMany({
17
+ where: {
18
+ expiresAt: {
19
+ lt: new Date(),
20
+ },
21
+ },
22
+ });
23
+
24
+ logger.info('Expired sessions cleaned up successfully', {
25
+ deletedCount: result.count,
26
+ });
27
+ } catch (error) {
28
+ logger.error(
29
+ 'Failed to cleanup expired sessions',
30
+ error instanceof Error ? error : new Error(String(error))
31
+ );
32
+ }
33
+ });
34
+
35
+ // Create a job that runs every 24 hours
36
+ const job = new SimpleIntervalJob(
37
+ { days: 1, runImmediately: false },
38
+ sessionCleanupTask
39
+ );
40
+
41
+ // Add the job to the scheduler
42
+ app.scheduler.addSimpleIntervalJob(job);
43
+
44
+ app.log.info('[+] Scheduled tasks configured');
45
+
46
+ app.addHook('onClose', async (instance) => {
47
+ instance.log.info('[-] Stopping scheduled tasks...');
48
+ instance.scheduler.stop();
49
+ });
50
+ };
51
+
52
+ export default fp(schedulePlugin);
@@ -0,0 +1,66 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+
4
+ import type { Env } from '@/config/env';
5
+ import { loadEnv } from '@/config/env';
6
+ import { AccountsService } from '@/services/accounts.service';
7
+ import { AuthorizationService } from '@/services/authorization.service';
8
+ import { EmailService } from '@/services/email.service';
9
+ import { FileStorageService } from '@/services/file-storage.service';
10
+ import { PasswordService } from '@/services/password.service';
11
+ import { SessionsService } from '@/services/sessions.service';
12
+ import { StatsService } from '@/services/stats.service';
13
+ import { UploadsService } from '@/services/uploads.service';
14
+ import { UsersService } from '@/services/users.service';
15
+
16
+ declare module 'fastify' {
17
+ interface FastifyInstance {
18
+ env: Env;
19
+ authorizationService: AuthorizationService;
20
+ usersService: UsersService;
21
+ sessionsService: SessionsService;
22
+ passwordService: PasswordService;
23
+ emailService: EmailService;
24
+ fileStorageService: FileStorageService;
25
+ uploadsService: UploadsService;
26
+ accountsService: AccountsService;
27
+ statsService: StatsService;
28
+ }
29
+ }
30
+
31
+ const servicesPlugin: FastifyPluginAsync = async (app) => {
32
+ const env = loadEnv();
33
+
34
+ const authorizationService = new AuthorizationService(app.logger);
35
+ const emailService = new EmailService(env, app.logger, app.prisma);
36
+ const fileStorageService = new FileStorageService(env, app.logger);
37
+ const usersService = new UsersService(
38
+ app.prisma,
39
+ app.logger,
40
+ authorizationService
41
+ );
42
+ const sessionsService = new SessionsService(app.prisma, authorizationService);
43
+ const passwordService = new PasswordService(app.prisma, sessionsService);
44
+ const uploadsService = new UploadsService(
45
+ app.prisma,
46
+ fileStorageService,
47
+ app.logger
48
+ );
49
+ const accountsService = new AccountsService(app.prisma, app.logger);
50
+ const statsService = new StatsService(app.prisma, app.logger);
51
+
52
+ app.decorate('env', env);
53
+ app.decorate('authorizationService', authorizationService);
54
+ app.decorate('emailService', emailService);
55
+ app.decorate('fileStorageService', fileStorageService);
56
+ app.decorate('usersService', usersService);
57
+ app.decorate('sessionsService', sessionsService);
58
+ app.decorate('passwordService', passwordService);
59
+ app.decorate('uploadsService', uploadsService);
60
+ app.decorate('accountsService', accountsService);
61
+ app.decorate('statsService', statsService);
62
+
63
+ app.log.info('[+] Services configured');
64
+ };
65
+
66
+ export default fp(servicesPlugin);
@@ -0,0 +1,56 @@
1
+ import swagger from '@fastify/swagger';
2
+ import type { FastifyPluginAsync } from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+ import {
5
+ jsonSchemaTransform,
6
+ type ZodTypeProvider,
7
+ } from 'fastify-type-provider-zod';
8
+
9
+ import { loadEnv } from '@/config/env';
10
+
11
+ const swaggerPlugin: FastifyPluginAsync = async (app) => {
12
+ const env = loadEnv();
13
+
14
+ await app.withTypeProvider<ZodTypeProvider>().register(swagger, {
15
+ openapi: {
16
+ info: {
17
+ title: 'API',
18
+ description: 'Production-ready TypeScript API built with Fastify',
19
+ version: '1.0.0',
20
+ },
21
+ servers: [
22
+ {
23
+ url: env.API_URL,
24
+ description: `${env.NODE_ENV.charAt(0).toUpperCase() + env.NODE_ENV.slice(1)} server`,
25
+ },
26
+ ],
27
+ components: {
28
+ securitySchemes: {
29
+ cookieAuth: {
30
+ type: 'apiKey',
31
+ in: 'cookie',
32
+ name: 'better-auth.session_token',
33
+ },
34
+ },
35
+ },
36
+ security: [
37
+ {
38
+ cookieAuth: [],
39
+ },
40
+ ],
41
+ tags: [
42
+ { name: 'Health', description: 'Health check endpoints' },
43
+ { name: 'Users', description: 'User management endpoints' },
44
+ {
45
+ name: 'Auth',
46
+ description: 'Authentication endpoints (handled by Better Auth)',
47
+ },
48
+ { name: 'Sessions', description: 'Session management endpoints' },
49
+ { name: 'Password', description: 'Password management endpoints' },
50
+ ],
51
+ },
52
+ transform: jsonSchemaTransform,
53
+ });
54
+ };
55
+
56
+ export default fp(swaggerPlugin);
@@ -0,0 +1,91 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod';
3
+ import { z } from 'zod';
4
+
5
+ import { requireAuth } from '@/hooks/auth';
6
+
7
+ const accountsRoutes: FastifyPluginAsync = async (app) => {
8
+ const server = app.withTypeProvider<ZodTypeProvider>();
9
+
10
+ server.get(
11
+ '/accounts',
12
+ {
13
+ schema: {
14
+ description: 'Get all connected accounts for the authenticated user',
15
+ tags: ['Accounts'],
16
+ response: {
17
+ 200: z.object({
18
+ userId: z.string(),
19
+ hasPassword: z.boolean(),
20
+ connectedAccounts: z.array(
21
+ z.object({
22
+ providerId: z.string(),
23
+ accountId: z.string(),
24
+ connectedAt: z.date(),
25
+ scope: z.string().optional(),
26
+ })
27
+ ),
28
+ }),
29
+ },
30
+ },
31
+ preHandler: requireAuth,
32
+ },
33
+ async (request) => {
34
+ const userId = request.user!.id;
35
+ return app.accountsService.getUserAccounts(userId);
36
+ }
37
+ );
38
+
39
+ server.delete(
40
+ '/accounts/:providerId',
41
+ {
42
+ schema: {
43
+ description: 'Unlink an OAuth provider from the user account',
44
+ tags: ['Accounts'],
45
+ params: z.object({
46
+ providerId: z.enum(['google', 'github']),
47
+ }),
48
+ response: {
49
+ 200: z.object({
50
+ success: z.boolean(),
51
+ }),
52
+ },
53
+ },
54
+ preHandler: requireAuth,
55
+ },
56
+ async (request) => {
57
+ const userId = request.user!.id;
58
+ const { providerId } = request.params;
59
+ const result = await app.accountsService.unlinkAccount(
60
+ userId,
61
+ providerId
62
+ );
63
+
64
+ return result;
65
+ }
66
+ );
67
+
68
+ server.get(
69
+ '/accounts/can-change-password',
70
+ {
71
+ schema: {
72
+ description:
73
+ 'Check if user can change password (has password set via credential provider)',
74
+ tags: ['Accounts'],
75
+ response: {
76
+ 200: z.object({
77
+ canChangePassword: z.boolean(),
78
+ }),
79
+ },
80
+ },
81
+ preHandler: requireAuth,
82
+ },
83
+ async (request) => {
84
+ const userId = request.user!.id;
85
+ const canChange = await app.accountsService.canChangePassword(userId);
86
+ return { canChangePassword: canChange };
87
+ }
88
+ );
89
+ };
90
+
91
+ export default accountsRoutes;