servcraft 0.1.0 → 0.1.1

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 (216) hide show
  1. package/.claude/settings.local.json +29 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/README.md +1070 -1
  9. package/dist/cli/index.cjs +2026 -2168
  10. package/dist/cli/index.cjs.map +1 -1
  11. package/dist/cli/index.js +2026 -2168
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/index.cjs +595 -616
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +114 -52
  16. package/dist/index.d.ts +114 -52
  17. package/dist/index.js +595 -616
  18. package/dist/index.js.map +1 -1
  19. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  20. package/docs/DATABASE_MULTI_ORM.md +399 -0
  21. package/docs/PHASE1_BREAKDOWN.md +346 -0
  22. package/docs/PROGRESS.md +550 -0
  23. package/docs/modules/ANALYTICS.md +226 -0
  24. package/docs/modules/API-VERSIONING.md +252 -0
  25. package/docs/modules/AUDIT.md +192 -0
  26. package/docs/modules/AUTH.md +431 -0
  27. package/docs/modules/CACHE.md +346 -0
  28. package/docs/modules/EMAIL.md +254 -0
  29. package/docs/modules/FEATURE-FLAG.md +291 -0
  30. package/docs/modules/I18N.md +294 -0
  31. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  32. package/docs/modules/MFA.md +266 -0
  33. package/docs/modules/NOTIFICATION.md +311 -0
  34. package/docs/modules/OAUTH.md +237 -0
  35. package/docs/modules/PAYMENT.md +804 -0
  36. package/docs/modules/QUEUE.md +540 -0
  37. package/docs/modules/RATE-LIMIT.md +339 -0
  38. package/docs/modules/SEARCH.md +288 -0
  39. package/docs/modules/SECURITY.md +327 -0
  40. package/docs/modules/SESSION.md +382 -0
  41. package/docs/modules/SWAGGER.md +305 -0
  42. package/docs/modules/UPLOAD.md +296 -0
  43. package/docs/modules/USER.md +505 -0
  44. package/docs/modules/VALIDATION.md +294 -0
  45. package/docs/modules/WEBHOOK.md +270 -0
  46. package/docs/modules/WEBSOCKET.md +691 -0
  47. package/package.json +53 -38
  48. package/prisma/schema.prisma +395 -1
  49. package/src/cli/commands/add-module.ts +520 -87
  50. package/src/cli/commands/db.ts +3 -4
  51. package/src/cli/commands/docs.ts +256 -6
  52. package/src/cli/commands/generate.ts +12 -19
  53. package/src/cli/commands/init.ts +384 -214
  54. package/src/cli/index.ts +0 -4
  55. package/src/cli/templates/repository.ts +6 -1
  56. package/src/cli/templates/routes.ts +6 -21
  57. package/src/cli/utils/docs-generator.ts +6 -7
  58. package/src/cli/utils/env-manager.ts +717 -0
  59. package/src/cli/utils/field-parser.ts +16 -7
  60. package/src/cli/utils/interactive-prompt.ts +223 -0
  61. package/src/cli/utils/template-manager.ts +346 -0
  62. package/src/config/database.config.ts +183 -0
  63. package/src/config/env.ts +0 -10
  64. package/src/config/index.ts +0 -14
  65. package/src/core/server.ts +1 -1
  66. package/src/database/adapters/mongoose.adapter.ts +132 -0
  67. package/src/database/adapters/prisma.adapter.ts +118 -0
  68. package/src/database/connection.ts +190 -0
  69. package/src/database/interfaces/database.interface.ts +85 -0
  70. package/src/database/interfaces/index.ts +7 -0
  71. package/src/database/interfaces/repository.interface.ts +129 -0
  72. package/src/database/models/mongoose/index.ts +7 -0
  73. package/src/database/models/mongoose/payment.schema.ts +347 -0
  74. package/src/database/models/mongoose/user.schema.ts +154 -0
  75. package/src/database/prisma.ts +1 -4
  76. package/src/database/redis.ts +101 -0
  77. package/src/database/repositories/mongoose/index.ts +7 -0
  78. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  79. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  80. package/src/database/seed.ts +6 -1
  81. package/src/index.ts +9 -20
  82. package/src/middleware/security.ts +2 -6
  83. package/src/modules/analytics/analytics.routes.ts +80 -0
  84. package/src/modules/analytics/analytics.service.ts +364 -0
  85. package/src/modules/analytics/index.ts +18 -0
  86. package/src/modules/analytics/types.ts +180 -0
  87. package/src/modules/api-versioning/index.ts +15 -0
  88. package/src/modules/api-versioning/types.ts +86 -0
  89. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  90. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  91. package/src/modules/api-versioning/versioning.service.ts +189 -0
  92. package/src/modules/audit/audit.repository.ts +206 -0
  93. package/src/modules/audit/audit.service.ts +27 -59
  94. package/src/modules/auth/auth.controller.ts +2 -2
  95. package/src/modules/auth/auth.middleware.ts +3 -9
  96. package/src/modules/auth/auth.routes.ts +10 -107
  97. package/src/modules/auth/auth.service.ts +126 -23
  98. package/src/modules/auth/index.ts +3 -4
  99. package/src/modules/cache/cache.service.ts +367 -0
  100. package/src/modules/cache/index.ts +10 -0
  101. package/src/modules/cache/types.ts +44 -0
  102. package/src/modules/email/email.service.ts +3 -10
  103. package/src/modules/email/templates.ts +2 -8
  104. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  105. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  106. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  107. package/src/modules/feature-flag/index.ts +20 -0
  108. package/src/modules/feature-flag/types.ts +192 -0
  109. package/src/modules/i18n/i18n.middleware.ts +186 -0
  110. package/src/modules/i18n/i18n.routes.ts +191 -0
  111. package/src/modules/i18n/i18n.service.ts +456 -0
  112. package/src/modules/i18n/index.ts +18 -0
  113. package/src/modules/i18n/types.ts +118 -0
  114. package/src/modules/media-processing/index.ts +17 -0
  115. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  116. package/src/modules/media-processing/media-processing.service.ts +245 -0
  117. package/src/modules/media-processing/types.ts +156 -0
  118. package/src/modules/mfa/index.ts +20 -0
  119. package/src/modules/mfa/mfa.repository.ts +206 -0
  120. package/src/modules/mfa/mfa.routes.ts +595 -0
  121. package/src/modules/mfa/mfa.service.ts +572 -0
  122. package/src/modules/mfa/totp.ts +150 -0
  123. package/src/modules/mfa/types.ts +57 -0
  124. package/src/modules/notification/index.ts +20 -0
  125. package/src/modules/notification/notification.repository.ts +356 -0
  126. package/src/modules/notification/notification.service.ts +483 -0
  127. package/src/modules/notification/types.ts +119 -0
  128. package/src/modules/oauth/index.ts +20 -0
  129. package/src/modules/oauth/oauth.repository.ts +219 -0
  130. package/src/modules/oauth/oauth.routes.ts +446 -0
  131. package/src/modules/oauth/oauth.service.ts +293 -0
  132. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  133. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  134. package/src/modules/oauth/providers/github.provider.ts +248 -0
  135. package/src/modules/oauth/providers/google.provider.ts +189 -0
  136. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  137. package/src/modules/oauth/types.ts +94 -0
  138. package/src/modules/payment/index.ts +19 -0
  139. package/src/modules/payment/payment.repository.ts +733 -0
  140. package/src/modules/payment/payment.routes.ts +390 -0
  141. package/src/modules/payment/payment.service.ts +354 -0
  142. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  143. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  144. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  145. package/src/modules/payment/types.ts +140 -0
  146. package/src/modules/queue/cron.ts +438 -0
  147. package/src/modules/queue/index.ts +87 -0
  148. package/src/modules/queue/queue.routes.ts +600 -0
  149. package/src/modules/queue/queue.service.ts +842 -0
  150. package/src/modules/queue/types.ts +222 -0
  151. package/src/modules/queue/workers.ts +366 -0
  152. package/src/modules/rate-limit/index.ts +59 -0
  153. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  154. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  155. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  156. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  157. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  158. package/src/modules/rate-limit/types.ts +153 -0
  159. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  160. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  161. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  162. package/src/modules/search/index.ts +21 -0
  163. package/src/modules/search/search.service.ts +234 -0
  164. package/src/modules/search/types.ts +214 -0
  165. package/src/modules/security/index.ts +40 -0
  166. package/src/modules/security/sanitize.ts +223 -0
  167. package/src/modules/security/security-audit.service.ts +388 -0
  168. package/src/modules/security/security.middleware.ts +398 -0
  169. package/src/modules/session/index.ts +3 -0
  170. package/src/modules/session/session.repository.ts +159 -0
  171. package/src/modules/session/session.service.ts +340 -0
  172. package/src/modules/session/types.ts +38 -0
  173. package/src/modules/swagger/index.ts +7 -1
  174. package/src/modules/swagger/schema-builder.ts +16 -4
  175. package/src/modules/swagger/swagger.service.ts +9 -10
  176. package/src/modules/swagger/types.ts +0 -2
  177. package/src/modules/upload/index.ts +14 -0
  178. package/src/modules/upload/types.ts +83 -0
  179. package/src/modules/upload/upload.repository.ts +199 -0
  180. package/src/modules/upload/upload.routes.ts +311 -0
  181. package/src/modules/upload/upload.service.ts +448 -0
  182. package/src/modules/user/index.ts +3 -3
  183. package/src/modules/user/user.controller.ts +15 -9
  184. package/src/modules/user/user.repository.ts +237 -113
  185. package/src/modules/user/user.routes.ts +39 -164
  186. package/src/modules/user/user.service.ts +4 -3
  187. package/src/modules/validation/validator.ts +12 -17
  188. package/src/modules/webhook/index.ts +91 -0
  189. package/src/modules/webhook/retry.ts +196 -0
  190. package/src/modules/webhook/signature.ts +135 -0
  191. package/src/modules/webhook/types.ts +181 -0
  192. package/src/modules/webhook/webhook.repository.ts +358 -0
  193. package/src/modules/webhook/webhook.routes.ts +442 -0
  194. package/src/modules/webhook/webhook.service.ts +457 -0
  195. package/src/modules/websocket/features.ts +504 -0
  196. package/src/modules/websocket/index.ts +106 -0
  197. package/src/modules/websocket/middlewares.ts +298 -0
  198. package/src/modules/websocket/types.ts +181 -0
  199. package/src/modules/websocket/websocket.service.ts +692 -0
  200. package/src/utils/errors.ts +7 -0
  201. package/src/utils/pagination.ts +4 -1
  202. package/tests/helpers/db-check.ts +79 -0
  203. package/tests/integration/auth-redis.test.ts +94 -0
  204. package/tests/integration/cache-redis.test.ts +387 -0
  205. package/tests/integration/mongoose-repositories.test.ts +410 -0
  206. package/tests/integration/payment-prisma.test.ts +637 -0
  207. package/tests/integration/queue-bullmq.test.ts +417 -0
  208. package/tests/integration/user-prisma.test.ts +441 -0
  209. package/tests/integration/websocket-socketio.test.ts +552 -0
  210. package/tests/setup.ts +11 -9
  211. package/vitest.config.ts +3 -8
  212. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  213. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  216. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,219 @@
1
+ /**
2
+ * OAuth Repository
3
+ * Prisma-based persistence for linked OAuth accounts
4
+ */
5
+ import { Prisma } from '@prisma/client';
6
+ import type {
7
+ LinkedAccount as PrismaLinkedAccount,
8
+ OAuthProvider as PrismaOAuthProvider,
9
+ PrismaClient,
10
+ } from '@prisma/client';
11
+ import type { LinkedAccount, OAuthProvider } from './types.js';
12
+
13
+ // Enum mappings (Prisma UPPERCASE ↔ Application lowercase)
14
+ const providerToPrisma: Record<OAuthProvider, PrismaOAuthProvider> = {
15
+ google: 'GOOGLE',
16
+ facebook: 'FACEBOOK',
17
+ github: 'GITHUB',
18
+ twitter: 'TWITTER',
19
+ apple: 'APPLE',
20
+ };
21
+
22
+ const providerFromPrisma: Record<PrismaOAuthProvider, OAuthProvider> = {
23
+ GOOGLE: 'google',
24
+ FACEBOOK: 'facebook',
25
+ GITHUB: 'github',
26
+ TWITTER: 'twitter',
27
+ APPLE: 'apple',
28
+ };
29
+
30
+ export class OAuthRepository {
31
+ constructor(private prisma: PrismaClient) {}
32
+
33
+ /**
34
+ * Create a linked account
35
+ */
36
+ async create(
37
+ data: Omit<LinkedAccount, 'id' | 'createdAt' | 'updatedAt'>
38
+ ): Promise<LinkedAccount> {
39
+ const account = await this.prisma.linkedAccount.create({
40
+ data: {
41
+ userId: data.userId,
42
+ provider: providerToPrisma[data.provider],
43
+ providerAccountId: data.providerAccountId,
44
+ email: data.email,
45
+ name: data.name,
46
+ picture: data.picture,
47
+ accessToken: data.accessToken,
48
+ refreshToken: data.refreshToken,
49
+ expiresAt: data.expiresAt,
50
+ },
51
+ });
52
+
53
+ return this.mapFromPrisma(account);
54
+ }
55
+
56
+ /**
57
+ * Get linked account by ID
58
+ */
59
+ async getById(id: string): Promise<LinkedAccount | null> {
60
+ const account = await this.prisma.linkedAccount.findUnique({
61
+ where: { id },
62
+ });
63
+
64
+ return account ? this.mapFromPrisma(account) : null;
65
+ }
66
+
67
+ /**
68
+ * Find linked account by provider and provider account ID
69
+ */
70
+ async findByProviderAccount(
71
+ provider: OAuthProvider,
72
+ providerAccountId: string
73
+ ): Promise<LinkedAccount | null> {
74
+ const account = await this.prisma.linkedAccount.findUnique({
75
+ where: {
76
+ provider_providerAccountId: {
77
+ provider: providerToPrisma[provider],
78
+ providerAccountId,
79
+ },
80
+ },
81
+ });
82
+
83
+ return account ? this.mapFromPrisma(account) : null;
84
+ }
85
+
86
+ /**
87
+ * Get all linked accounts for a user
88
+ */
89
+ async getByUserId(userId: string): Promise<LinkedAccount[]> {
90
+ const accounts = await this.prisma.linkedAccount.findMany({
91
+ where: { userId },
92
+ orderBy: { createdAt: 'desc' },
93
+ });
94
+
95
+ return accounts.map((a) => this.mapFromPrisma(a));
96
+ }
97
+
98
+ /**
99
+ * Get linked account by user and provider
100
+ */
101
+ async getByUserAndProvider(
102
+ userId: string,
103
+ provider: OAuthProvider
104
+ ): Promise<LinkedAccount | null> {
105
+ const account = await this.prisma.linkedAccount.findFirst({
106
+ where: {
107
+ userId,
108
+ provider: providerToPrisma[provider],
109
+ },
110
+ });
111
+
112
+ return account ? this.mapFromPrisma(account) : null;
113
+ }
114
+
115
+ /**
116
+ * Update linked account
117
+ */
118
+ async update(
119
+ id: string,
120
+ data: Partial<
121
+ Pick<
122
+ LinkedAccount,
123
+ 'email' | 'name' | 'picture' | 'accessToken' | 'refreshToken' | 'expiresAt'
124
+ >
125
+ >
126
+ ): Promise<LinkedAccount | null> {
127
+ try {
128
+ const updateData: Prisma.LinkedAccountUpdateInput = {};
129
+
130
+ if (data.email !== undefined) updateData.email = data.email;
131
+ if (data.name !== undefined) updateData.name = data.name;
132
+ if (data.picture !== undefined) updateData.picture = data.picture;
133
+ if (data.accessToken !== undefined) updateData.accessToken = data.accessToken;
134
+ if (data.refreshToken !== undefined) updateData.refreshToken = data.refreshToken;
135
+ if (data.expiresAt !== undefined) updateData.expiresAt = data.expiresAt;
136
+
137
+ const account = await this.prisma.linkedAccount.update({
138
+ where: { id },
139
+ data: updateData,
140
+ });
141
+
142
+ return this.mapFromPrisma(account);
143
+ } catch (error) {
144
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
145
+ return null;
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Delete linked account by ID
153
+ */
154
+ async delete(id: string): Promise<boolean> {
155
+ try {
156
+ await this.prisma.linkedAccount.delete({ where: { id } });
157
+ return true;
158
+ } catch (error) {
159
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
160
+ return false;
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Delete linked account by user and provider
168
+ */
169
+ async deleteByUserAndProvider(userId: string, provider: OAuthProvider): Promise<boolean> {
170
+ const result = await this.prisma.linkedAccount.deleteMany({
171
+ where: {
172
+ userId,
173
+ provider: providerToPrisma[provider],
174
+ },
175
+ });
176
+
177
+ return result.count > 0;
178
+ }
179
+
180
+ /**
181
+ * Delete all linked accounts for a user
182
+ */
183
+ async deleteByUserId(userId: string): Promise<number> {
184
+ const result = await this.prisma.linkedAccount.deleteMany({
185
+ where: { userId },
186
+ });
187
+
188
+ return result.count;
189
+ }
190
+
191
+ /**
192
+ * Count linked accounts for a user
193
+ */
194
+ async countByUser(userId: string): Promise<number> {
195
+ return this.prisma.linkedAccount.count({
196
+ where: { userId },
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Map Prisma model to application type
202
+ */
203
+ private mapFromPrisma(prismaAccount: PrismaLinkedAccount): LinkedAccount {
204
+ return {
205
+ id: prismaAccount.id,
206
+ userId: prismaAccount.userId,
207
+ provider: providerFromPrisma[prismaAccount.provider],
208
+ providerAccountId: prismaAccount.providerAccountId,
209
+ email: prismaAccount.email || undefined,
210
+ name: prismaAccount.name || undefined,
211
+ picture: prismaAccount.picture || undefined,
212
+ accessToken: prismaAccount.accessToken || undefined,
213
+ refreshToken: prismaAccount.refreshToken || undefined,
214
+ expiresAt: prismaAccount.expiresAt || undefined,
215
+ createdAt: prismaAccount.createdAt,
216
+ updatedAt: prismaAccount.updatedAt,
217
+ };
218
+ }
219
+ }
@@ -0,0 +1,446 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+ import type { AuthService } from '../auth/auth.service.js';
3
+ import { createAuthMiddleware } from '../auth/auth.middleware.js';
4
+ import { commonResponses } from '../swagger/index.js';
5
+ import { getOAuthService } from './oauth.service.js';
6
+ import type { OAuthProvider, OAuthCallbackParams } from './types.js';
7
+
8
+ const oauthTag = 'OAuth';
9
+
10
+ export function registerOAuthRoutes(app: FastifyInstance, authService: AuthService): void {
11
+ const authenticate = createAuthMiddleware(authService);
12
+ const oauthService = getOAuthService();
13
+
14
+ // Get supported providers
15
+ app.get(
16
+ '/auth/oauth/providers',
17
+ {
18
+ schema: {
19
+ tags: [oauthTag],
20
+ summary: 'Get supported OAuth providers',
21
+ response: {
22
+ 200: {
23
+ type: 'object',
24
+ properties: {
25
+ success: { type: 'boolean' },
26
+ data: {
27
+ type: 'object',
28
+ properties: {
29
+ providers: {
30
+ type: 'array',
31
+ items: {
32
+ type: 'string',
33
+ enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ async (_request: FastifyRequest, reply: FastifyReply) => {
44
+ const providers = oauthService.getSupportedProviders();
45
+ return reply.send({ success: true, data: { providers } });
46
+ }
47
+ );
48
+
49
+ // Initiate OAuth flow
50
+ app.get(
51
+ '/auth/oauth/:provider',
52
+ {
53
+ schema: {
54
+ tags: [oauthTag],
55
+ summary: 'Initiate OAuth authentication',
56
+ description: 'Redirects to the OAuth provider for authentication',
57
+ params: {
58
+ type: 'object',
59
+ properties: {
60
+ provider: {
61
+ type: 'string',
62
+ enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
63
+ },
64
+ },
65
+ required: ['provider'],
66
+ },
67
+ querystring: {
68
+ type: 'object',
69
+ properties: {
70
+ redirect: { type: 'string', description: 'URL to redirect after auth' },
71
+ },
72
+ },
73
+ response: {
74
+ 302: { description: 'Redirect to OAuth provider' },
75
+ 400: commonResponses.error,
76
+ },
77
+ },
78
+ },
79
+ async (
80
+ request: FastifyRequest<{
81
+ Params: { provider: string };
82
+ Querystring: { redirect?: string };
83
+ }>,
84
+ reply: FastifyReply
85
+ ) => {
86
+ const provider = request.params.provider as OAuthProvider;
87
+
88
+ if (!oauthService.isProviderEnabled(provider)) {
89
+ return reply.status(400).send({
90
+ success: false,
91
+ message: `OAuth provider '${provider}' is not configured`,
92
+ });
93
+ }
94
+
95
+ const { url, state } = await oauthService.getAuthorizationUrl(provider);
96
+
97
+ // Store redirect URL in cookie if provided
98
+ if (request.query.redirect) {
99
+ reply.setCookie('oauth_redirect', request.query.redirect, {
100
+ httpOnly: true,
101
+ secure: process.env.NODE_ENV === 'production',
102
+ maxAge: 600, // 10 minutes
103
+ path: '/',
104
+ });
105
+ }
106
+
107
+ // Store state in cookie for CSRF protection
108
+ reply.setCookie('oauth_state', state, {
109
+ httpOnly: true,
110
+ secure: process.env.NODE_ENV === 'production',
111
+ maxAge: 600,
112
+ path: '/',
113
+ });
114
+
115
+ return reply.redirect(url);
116
+ }
117
+ );
118
+
119
+ // OAuth callback
120
+ app.get(
121
+ '/auth/oauth/:provider/callback',
122
+ {
123
+ schema: {
124
+ tags: [oauthTag],
125
+ summary: 'OAuth callback endpoint',
126
+ description: 'Handles the OAuth provider callback after authentication',
127
+ params: {
128
+ type: 'object',
129
+ properties: {
130
+ provider: {
131
+ type: 'string',
132
+ enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
133
+ },
134
+ },
135
+ required: ['provider'],
136
+ },
137
+ querystring: {
138
+ type: 'object',
139
+ properties: {
140
+ code: { type: 'string' },
141
+ state: { type: 'string' },
142
+ error: { type: 'string' },
143
+ error_description: { type: 'string' },
144
+ },
145
+ },
146
+ response: {
147
+ 200: {
148
+ type: 'object',
149
+ properties: {
150
+ success: { type: 'boolean' },
151
+ data: {
152
+ type: 'object',
153
+ properties: {
154
+ user: { type: 'object' },
155
+ accessToken: { type: 'string' },
156
+ refreshToken: { type: 'string' },
157
+ isNewUser: { type: 'boolean' },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ 400: commonResponses.error,
163
+ },
164
+ },
165
+ },
166
+ async (
167
+ request: FastifyRequest<{
168
+ Params: { provider: string };
169
+ Querystring: OAuthCallbackParams;
170
+ }>,
171
+ reply: FastifyReply
172
+ ) => {
173
+ const provider = request.params.provider as OAuthProvider;
174
+ const { code, state, error, errorDescription } = request.query;
175
+
176
+ // Check for OAuth error
177
+ if (error) {
178
+ return reply.status(400).send({
179
+ success: false,
180
+ message: errorDescription || error,
181
+ });
182
+ }
183
+
184
+ if (!code || !state) {
185
+ return reply.status(400).send({
186
+ success: false,
187
+ message: 'Missing code or state parameter',
188
+ });
189
+ }
190
+
191
+ // Verify state from cookie
192
+ const storedState = request.cookies.oauth_state;
193
+ if (storedState !== state) {
194
+ return reply.status(400).send({
195
+ success: false,
196
+ message: 'Invalid state parameter',
197
+ });
198
+ }
199
+
200
+ // Clear state cookie
201
+ reply.clearCookie('oauth_state', { path: '/' });
202
+
203
+ try {
204
+ // Handle OAuth callback
205
+ const oauthUser = await oauthService.handleCallback(provider, code, state);
206
+
207
+ // Check if user exists by linked account
208
+ let userId = await oauthService.findUserByOAuth(provider, oauthUser.providerAccountId);
209
+ let isNewUser = false;
210
+
211
+ if (!userId) {
212
+ // Check if user exists by email
213
+ if (oauthUser.email) {
214
+ const existingUser = await authService.findUserByEmail(oauthUser.email);
215
+ if (existingUser) {
216
+ userId = existingUser.id;
217
+ // Link the account
218
+ await oauthService.linkAccount(userId, oauthUser);
219
+ }
220
+ }
221
+
222
+ // Create new user if not found
223
+ if (!userId) {
224
+ const newUser = await authService.createUserFromOAuth({
225
+ email: oauthUser.email || `${oauthUser.providerAccountId}@${provider}.oauth`,
226
+ name: oauthUser.name ?? undefined,
227
+ picture: oauthUser.picture ?? undefined,
228
+ emailVerified: oauthUser.emailVerified,
229
+ });
230
+ userId = newUser.id;
231
+ isNewUser = true;
232
+ await oauthService.linkAccount(userId, oauthUser);
233
+ }
234
+ }
235
+
236
+ // Generate JWT tokens
237
+ const tokens = await authService.generateTokensForUser(userId);
238
+
239
+ // Get redirect URL from cookie
240
+ const redirectUrl = request.cookies.oauth_redirect;
241
+ reply.clearCookie('oauth_redirect', { path: '/' });
242
+
243
+ // If redirect URL is provided, redirect with tokens
244
+ if (redirectUrl) {
245
+ const url = new URL(redirectUrl);
246
+ url.searchParams.set('access_token', tokens.accessToken);
247
+ url.searchParams.set('refresh_token', tokens.refreshToken);
248
+ url.searchParams.set('is_new_user', String(isNewUser));
249
+ return reply.redirect(url.toString());
250
+ }
251
+
252
+ // Otherwise return JSON response
253
+ return reply.send({
254
+ success: true,
255
+ data: {
256
+ user: {
257
+ id: userId,
258
+ email: oauthUser.email,
259
+ name: oauthUser.name,
260
+ picture: oauthUser.picture,
261
+ },
262
+ accessToken: tokens.accessToken,
263
+ refreshToken: tokens.refreshToken,
264
+ isNewUser,
265
+ },
266
+ });
267
+ } catch (err) {
268
+ const message = err instanceof Error ? err.message : 'OAuth authentication failed';
269
+ return reply.status(400).send({ success: false, message });
270
+ }
271
+ }
272
+ );
273
+
274
+ // Link OAuth account (authenticated users)
275
+ app.post(
276
+ '/auth/oauth/:provider/link',
277
+ {
278
+ preHandler: [authenticate],
279
+ schema: {
280
+ tags: [oauthTag],
281
+ summary: 'Link OAuth account to current user',
282
+ description: 'Initiates the process to link an OAuth account to the authenticated user',
283
+ security: [{ bearerAuth: [] }],
284
+ params: {
285
+ type: 'object',
286
+ properties: {
287
+ provider: {
288
+ type: 'string',
289
+ enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
290
+ },
291
+ },
292
+ required: ['provider'],
293
+ },
294
+ response: {
295
+ 200: {
296
+ type: 'object',
297
+ properties: {
298
+ success: { type: 'boolean' },
299
+ data: {
300
+ type: 'object',
301
+ properties: {
302
+ authUrl: { type: 'string' },
303
+ },
304
+ },
305
+ },
306
+ },
307
+ 400: commonResponses.error,
308
+ 401: commonResponses.unauthorized,
309
+ },
310
+ },
311
+ },
312
+ async (request: FastifyRequest, reply: FastifyReply) => {
313
+ const params = request.params as { provider: string };
314
+ const provider = params.provider as OAuthProvider;
315
+ const user = (request as FastifyRequest & { user: { id: string } }).user;
316
+
317
+ if (!oauthService.isProviderEnabled(provider)) {
318
+ return reply.status(400).send({
319
+ success: false,
320
+ message: `OAuth provider '${provider}' is not configured`,
321
+ });
322
+ }
323
+
324
+ const { url, state } = await oauthService.getAuthorizationUrl(provider);
325
+
326
+ // Store user ID in cookie for linking after callback
327
+ reply.setCookie('oauth_link_user', user.id, {
328
+ httpOnly: true,
329
+ secure: process.env.NODE_ENV === 'production',
330
+ maxAge: 600,
331
+ path: '/',
332
+ });
333
+
334
+ reply.setCookie('oauth_state', state, {
335
+ httpOnly: true,
336
+ secure: process.env.NODE_ENV === 'production',
337
+ maxAge: 600,
338
+ path: '/',
339
+ });
340
+
341
+ return reply.send({ success: true, data: { authUrl: url } });
342
+ }
343
+ );
344
+
345
+ // Unlink OAuth account
346
+ app.delete(
347
+ '/auth/oauth/:provider/unlink',
348
+ {
349
+ preHandler: [authenticate],
350
+ schema: {
351
+ tags: [oauthTag],
352
+ summary: 'Unlink OAuth account from current user',
353
+ security: [{ bearerAuth: [] }],
354
+ params: {
355
+ type: 'object',
356
+ properties: {
357
+ provider: {
358
+ type: 'string',
359
+ enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
360
+ },
361
+ },
362
+ required: ['provider'],
363
+ },
364
+ response: {
365
+ 200: {
366
+ type: 'object',
367
+ properties: {
368
+ success: { type: 'boolean' },
369
+ message: { type: 'string' },
370
+ },
371
+ },
372
+ 400: commonResponses.error,
373
+ 401: commonResponses.unauthorized,
374
+ 404: commonResponses.notFound,
375
+ },
376
+ },
377
+ },
378
+ async (request: FastifyRequest, reply: FastifyReply) => {
379
+ const params = request.params as { provider: string };
380
+ const provider = params.provider as OAuthProvider;
381
+ const user = (request as FastifyRequest & { user: { id: string } }).user;
382
+
383
+ await oauthService.unlinkAccount(user.id, provider);
384
+
385
+ return reply.send({
386
+ success: true,
387
+ message: `${provider} account unlinked successfully`,
388
+ });
389
+ }
390
+ );
391
+
392
+ // Get linked accounts
393
+ app.get(
394
+ '/auth/oauth/linked',
395
+ {
396
+ preHandler: [authenticate],
397
+ schema: {
398
+ tags: [oauthTag],
399
+ summary: 'Get linked OAuth accounts',
400
+ security: [{ bearerAuth: [] }],
401
+ response: {
402
+ 200: {
403
+ type: 'object',
404
+ properties: {
405
+ success: { type: 'boolean' },
406
+ data: {
407
+ type: 'object',
408
+ properties: {
409
+ accounts: {
410
+ type: 'array',
411
+ items: {
412
+ type: 'object',
413
+ properties: {
414
+ provider: { type: 'string' },
415
+ email: { type: 'string' },
416
+ name: { type: 'string' },
417
+ picture: { type: 'string' },
418
+ createdAt: { type: 'string', format: 'date-time' },
419
+ },
420
+ },
421
+ },
422
+ },
423
+ },
424
+ },
425
+ },
426
+ 401: commonResponses.unauthorized,
427
+ },
428
+ },
429
+ },
430
+ async (request: FastifyRequest, reply: FastifyReply) => {
431
+ const user = (request as FastifyRequest & { user: { id: string } }).user;
432
+ const accounts = await oauthService.getUserLinkedAccounts(user.id);
433
+
434
+ // Remove sensitive data
435
+ const safeAccounts = accounts.map((account) => ({
436
+ provider: account.provider,
437
+ email: account.email,
438
+ name: account.name,
439
+ picture: account.picture,
440
+ createdAt: account.createdAt,
441
+ }));
442
+
443
+ return reply.send({ success: true, data: { accounts: safeAccounts } });
444
+ }
445
+ );
446
+ }